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

MySQL · 数据恢复 · undrop-for-innodb

$
0
0

简介

undrop-for-innodb 是针对 innodb 的一套数据恢复工具,可以从文件级别恢复诸如:DROP/TRUNCATE table, 删除表中某些记录,innodb 文件被删除,文件系统损坏,磁盘 corruption 等几种情况。本文简单介绍下使用方法和原理浅析。

准备

git clone https://github.com/twindb/undrop-for-innodb.git 
make

需要联合 MySQL 的安装路径编译工具 sys_parser,

gcc `$basedir/bin/mysql_config --cflags` `$basedir/bin/mysql_config --libs` -o sys_parser sys_parser.c

需要的工具都已经完备:
420d94d6-79de-49b3-ad6c-c2648307d1dc.png

  • 重要的工具: c_parser && stream_parser && sys_parser
  • 其中 test.sh && recover_dictionary.sh && fetch_data.sh是测试的脚本,可以看下里面的逻辑理解工具的用法。
  • 三个目录
  • dictionary 里面是模拟 innodb 系统表结构写的 CREATE TABLE 语句,innodb 的系统表对用户不可见,可以在 informatioin_schema 表中找到一些值,但实际上系统表是保存在 ibdata 固定的页上的。
  • sakila 是一些 SQL 语句,用来测试用。
  • include 是从 innodb 拿出来的一些用到的头文件和源文件。

DROP TABLE

表结构恢复

一般情况下表结构是存储在 frm 文件中,drop table 会删除 frm 文件,还好我们可以从 innodb 系统表里读取一些信息恢复表结构。innodb 系统表有

SYS_COLUMNS | SYS_FIELDS | SYS_INDEXES | SYS_TABLES 

关于系统表结构的具体介绍可以参考 系统表 , 这几个表对于恢复非常重要,下面以一个恢复表结构的例子来说明。

使用目录 sakila/actor.sql 中的例子:

CREATE TABLE `actor` (
  `actor_id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8;

insert into actor(first_name, last_name) values('zhang', 'jian');
insert into actor(first_name, last_name) values('zhan', 'jian');
insert into actor(first_name, last_name) values('zha', 'jian');
insert into actor(first_name, last_name) values('zh', 'jian');
insert into actor(first_name, last_name) values('z', 'jian');

mysql> checksum table actor;
+-----------+------------+
| Table     | Checksum   |
+-----------+------------+
| per.actor | 2184463059 |
+-----------+------------+
1 row in set (0.00 sec)
DROP TABLE actor

需要从系统表中恢复,而系统表是保存在 $datadir/ibdata1 文件中的,使用工具 stream_parser解析文件内容。

$./stream_parser -f /home/zj118228/rds_5616/data/ibdata1

执行完毕后会在当前目录下生成文件夹 pages-ibdata1 , 目录下按照每个页为一个文件,分为索引页和数据较大的 BLOB 页,我们访问系统表的话,是存在索引页中的。使用另外一个重要的工具 c_parser来解析页的内容。

$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000001.page -t dictionary/SYS_TABLES.sql  | grep 'sakila/actor'
000000005927 24000001C809C6 SYS_TABLES "sakila/actor" 52 4 1 0 80 "" 38

参数解析:

  • 4 表示文件格式是 REDUNDANT,系统表的格式默认值。另外可以取值 5 表示 COMPACT 格式,6 表示 MySQL 5.6 格式。
  • D 表示只恢复被删除的记录。
  • f 后面跟着文件。
  • t 后面跟着 CREATE TABLE 语句,需要根据表的格式来解析文件。

得到的结果 ‘SYS_TABLES’ 字段后面的就是系统表 SYS_TABLE 中对应存的记录。 同样的恢复其它三个系统表:

/* --- SYS_INDEXES 'grep 52'是对应 SYS_TABLE 的 TALE ID --- */ 
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000003.page -t dictionary/SYS_INDEXES.sql  | grep '52'
000000005927 24000001C807FF SYS_INDEXES 52 57 "PRIMARY" 1 3 38 4294967295
000000005927 24000001C80871 SYS_INDEXES 52 58 "idx\_actor\_last\_name" 1 0 38 4294967295

/* --- SYS_COLUMNS --- */
./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000002.page -t dictionary/SYS_COLUMNS.sql | grep 52
000000005927 24000001C808F2 SYS_COLUMNS 52 0 "actor\_id" 6 1794 2 0
000000005927 24000001C80927 SYS_COLUMNS 52 1 "first\_name" 12 2162959 135 0
000000005927 24000001C8095C SYS_COLUMNS 52 2 "last\_name" 12 2162959 135 0
000000005927 24000001C80991 SYS_COLUMNS 52 3 "last\_update" 3 525575 4 0

/* --- SYS_FIELD  'grep 57\|58'对应 SYS_INDEXES 的 ID 列 --- */
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000004.page -t dictionary/SYS_FIELDS.sql | grep '57\|58'
000000005927 24000001C807CA SYS_FIELDS 57 0 "actor\_id"
000000005927 24000001C8083C SYS_FIELDS 58 0 "last\_name"

我们恢复表结构的数据都在这四张系统表中了,SYS_COLUMNS 后面几列的表示 MySQL 内部对于数据类型的编号。

接下来是恢复阶段
1. 使用目录 dictionary 下的四个文件创建四张表(这里数据库名为 recover )。
2. 把上面恢复出来的数据分别导入到对应的表中(注意相同的行去重)。
3. 使用工具 sys_parser 恢复 CREATE TABLE 语句。

$./sys_parser -h 127.0.0.1 -u root -P 56160 -d recover sakila/actor
CREATE TABLE `actor`(
 `actor_id` SMALLINT UNSIGNED NOT NULL,
 `first_name` VARCHAR(45) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NOT NULL,
 `last_name` VARCHAR(45) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NOT NULL,
 `last_update` TIMESTAMP NOT NULL,
 PRIMARY KEY (`actor_id`)
) ENGINE=InnoDB;

对比发现,恢复出来的 CREATE TABLE 语句相比原来创建的语句信息量有点缺少,因为 innodb 系统表里面存的数据相比 frm 文件是不足的,比如 AUTO_INCREMENT, DECIMAL 类型的精度信息都会缺失,也不会恢复二级索引,外建等。可以看成是表存储结构的恢复。如果有 frm 文件就可以完完整整的恢复了,这篇文章介绍了恢复方法:Get Create Table From frm

表数据恢复

innodb_file_per_table off

这种情况表中的数据是保存在 ibdata 文件中的,虽然表的数据在数据库中被删除了,但是如果没有被重写,数据还在保存在文件中的,执行下列步骤来恢复:
1. 使用 stream_parser 分析 ibdata 文件,分别得到每个页的文件。

 $./stream_parser -f /home/zj118228/rds_5616/data/ibdata1
  1. 如表结构分析小节中所示,使用 c_parser分析系统表 SYS_TABLES 和 SYS_INDEXES ,根据表名得到 TABLE ID, 根据 TABLE ID 得到 INDEX ID。(INDEX ID 就是上述例子的第 5 列,值为 57 和 58)
  2. 根据得到的 INDEX ID,到目录 pages-ibdata1 下去找对应的页号,这就是对应的索引表数据所在的数据页。
  3. 使用 c_parser 读取第 3 步得到的页文件,得到数据。
$./c_parser -6f pages-ibdata1/FIL_PAGE_INDEX/0000000000000065.page -t actor.sql
-- Page id: 579, Format: COMPACT, Records list: Valid, Expected records: (5 5)
000000005D95 E5000001960110 actor 201 "zhang""jian""2017-11-04 12:30:07"
000000005D96 E6000001970110 actor 202 "zhan""jian""2017-11-04 12:30:07"
000000005D98 E80000019A0110 actor 203 "zha""jian""2017-11-04 12:30:07"
000000005D99 E90000019B0110 actor 204 "zh""jian""2017-11-04 12:30:07"
000000005DA9 F1000002480110 actor 205 "z""jian""2017-11-04 12:30:08"

数据看起来没什么问题,表结构和数据都有了,导进去即可,看一下 checksum 也相同。

mysql> checksum table actor;
+-----------+------------+
| Table     | Checksum   |
+-----------+------------+
| per.actor | 2184463059 |
+-----------+------------+
1 row in set (0.00 sec)

innodb_file_per_table on

这种情况下表是保存在各自的 ibd 文件中的,当 drop table 之后 ,ibd 文件会被删除,此时最好能够设置磁盘整体只读,避免有其它进程重写文件块。整体的恢复步骤和上一个小节(innodb_file_per_table off) 相同,只是无法从 pages-ibdata1 目录下面找到对应的 page 号。
假设已经完成了前两步,拿到了 INDEX ID。

stream_parser这个工具不但可以读文件,还可以读磁盘,会根据 innodb 数据格式把数据页读出来。为了恢复 68 号数据页,我们执行下面几个步骤:
1.找到被删除的 ibd 文件的挂载磁盘 /dev/sda5:

$df 
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/sda2             52327276  47003636   2702200  95% /
tmpfs                 99225896   9741300  89484596  10% /dev/shm
/dev/sda1               258576     55291    190229  23% /boot
/dev/sda5            1350345636 1142208356 208137280  85% /home
/dev/sdb1            3278622264 2277365092 1001257172  70% /disk1

2.根据 INDEX ID , 磁盘大小执行 stream_parser,-t 表示磁盘的大小。

 $./stream_parser -f /dev/sda5 -s 1G -t 1142G

3.在目录 pages—sda5 下找到 68 号页,像上一个小节第 4 步一样恢复数据即可。
4.<划重点>测试了三次,有两次是恢复不出来的,因为文件很可能被其它进程重写,这取决于文件系统调度还有整体服务器的负载。 5.如果挂载的磁盘上还有其它 mysqld 的数据目录,那么很可能一个 page 文件会很大,监测到其它 ibd 文件的数据,同一个页号的综合在一起,这样辨别出我们需要的数据就比较麻烦。划重点>

文件页脏写

MySQL 每次从磁盘读取数据的时候都会进行 checksum 校验,如果校验失败,整个进程就会重启或者退出,校验失败很可能是文件页被脏写了。使用恢复工具直接读取文件很可能可以把未被脏写的行或者页读取出来,损失降到最低。

模拟脏写

同样使用目录 sakila/actor.sql 中的例子,innodb_per_file_table = on:

CREATE TABLE `actor` (
  `actor_id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8;

insert into actor(first_name, last_name) values('zhang', 'jian');
insert into actor(first_name, last_name) values('zhan', 'jian');
insert into actor(first_name, last_name) values('zha', 'jian');
insert into actor(first_name, last_name) values('zh', 'jian');
insert into actor(first_name, last_name) values('z', 'jian');

模拟脏写,打开 actor.ibd 文件, 使用 ‘#’ 覆盖其中一行数据,
00fdb870-b7df-4122-b7d9-1622bc354737.png

从系统表空间确定 INDEX ID (参考 表结构恢复 小节)

$./stream_parser -f /home/zj118228/rds_5616/data/ibdata1
$./stream_parser -f ~/rds_5616/data/per/actor.ibd
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000001.page -t dictionary/SYS_TABLES.sql
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000003.page -t dictionary/SYS_INDEXES.sql

INDEX ID 为 76,读取数据:

$./c_parser -6f pages-actor.ibd/FIL_PAGE_INDEX/0000000000000076.page -t sakila/actor.sql

d27b1b03-ac82-4d60-9717-1c58d9587e64.png
看到有一行数据被 # 号覆盖,然后丢失了一行。

脏写之后数据库是起不来的,因为 ibd 文件已经损坏了,但此时我们已经拿到了恢复之后的数据,需要把恢复之后的数据导入到数据库里。导入之前删除 actor.ibd 文件,然后启动数据库后执行 drop table actor, 然后再重新创建表,导入数据即可。如果不小心把 frm 文件也删掉了,是没法 drop table 的,可以在其它数据库里建一个同名,结构相同的表生成 frm 文件,然后拷贝到被删除的目录下,然后再执行 drop table。参考:Troubleshooting

原理浅析

c_parser

恢复工具 c_parser其实是按照 innodb 存储数据的格式来分析哪些是我们需要的数据本身,所以页上的数据可以分为两类:1. 用户数据 2. 元数据。而元数据的功能其实并不相同,有些损坏无伤大雅,有些损坏却可能导致整个页无法恢复。这里有几篇介绍Innodb 行记录格式1 and Innodb 行记录格式2,上一个小节中行记录格式是 Compact,来分析一下为什么会丢了一行数据。

这是完好的数据页,上面是脏写是把第 12 行数据全部覆盖了,根据 Compact 类型的格式,12 行末尾的 04 03 表示下一行变长数据类型(‘zha’ ‘jian’)的长度倒序,被覆盖之后当然无法解析,于是就丢了一行。那么为什么没有影响后续的行数据呢?第 13 行第 2 列的数据 21 表示下行数据的偏移,幸运的没有被覆盖。如果这个字节被覆盖,那么整个格式就乱了,无法解析。
4abe1824-db59-46a4-a610-a24a3cd9bfd0.png

试了其它几种情况:

  • 第六行第五列 004C 表示 page 的号,破坏之后 stream 出来的页号会变,所以从 Innodb 系统表得到的主键索引页号就不对了。
  • infimumsupremum破坏之后 stream 无法检测出页,所以根本产生不了可恢复的数据。

stream_parser

c_parser是分析页面中用户的行数据,从参数中传入 CREATE TABLE语句,根据定义的数据格式逐行解析,得到最终恢复的数据。而 stream_parser是分析 ibd/ibdata 文件(或者挂载的磁盘),得到每一个数据页的。根据数据页的元数据,如果满足下列条件,就被认为是一个合法的 Innodb Index 数据页:

  • 页面最开始前四个字节(checksum)不为 0
  • 页面 5-8 字节(页面在 tablespace 中的偏移)不为零,且小于 (ib_size / UNIV_PAGE_SIZE) 最大偏移量,ibd 文件大小除以 Innodb 页大小。
  • 在固定偏移处找到 infimumsupremum

参考 stream_parser.c中的函数 valid_innodb_page, 关于 Blob page 判定条件略有不同,详细参考 valid_blob_page,这里以 Index page 为例。

得到一个合法的页后就以 UNIV_PAGE_SIZE 为大小写入到以 index_id 命名的文件中(也就是 c_parser读入的页号判断标准)。

页数据格式

这里引用下登博画的大图:
undefined

根据图中数据格式,如果页面前 8 字节被重写为 0 ,infimumsupremum被写坏,stream_parser无法检测出有效页。如果图中 Page_no 被写坏,那么我们从 Innodb 数据字典中获得的需要解析的文件页号恐怕就不对了,也不知道从那里去恢复。

所以这种恢复方式是寄托在重要页元数据和行元数据没有被脏写的前提下的,上述分析过后,重要的元数据所占比例较小,如果每个字节被脏写的概率相同,那么数据的可恢复性还是比较可观的。

最后,对于文件系统损坏或者磁盘 corruption,最重要的把数据拷贝出来,而不是去恢复文件系统或者磁盘,因为上述工具的恢复是基于数据的,参考这篇文章,第一时间使用 dd 命令制作磁盘镜像,再走上述的恢复流程即可。


MySQL · 引擎特性 · DROP TABLE之binlog解析

$
0
0

Drop Table的特殊之处

Drop Table乍一看,与其它DDL 也没什么区别,但当你深入去研究它的时候,发现还是有很多不同。最明显的地方就是DropTable后面可以紧跟多个表,并且可以是不同类型的表,这些表还不需要显式指明其类型,比如是普通表还是临时表,是支持事务的存储引擎的表还是不支持事务的存储引擎的表等。这些特殊之处对于代码实现有什么影响呢?对于普通表,无论是创建还是删除,数据库都会产生相应的binlog日志,而对于临时表来说,记录binlog日志就不是必须的。对于采用不同存储引擎的表来说,更是如此,有些存储引擎不支持事务如MyISAM,而有些存储引擎支持事务如Innodb,对于支持事务和不支持事务的存储引擎,处理方式也有些许差异。而Drop Table可以跟多种不同类型的表,就必须对这些情况分类处理。因此有必要对MySQL的DROP TABLE实现进行更深入的研究,以了解个中不同之处,防止被误解误用。

MySQL中Drop Table不支持事务

MySQL中对于DDL本身的实现与其它数据库也存在一些不同,比如无论存储引擎是什么,支持事务Innodb或是不支持事务MyISAM,MySQL的DDL都不支持事务,也不能被包含在一个长事务中,即使用begin/end或start transaction/commit包含多条语句的事务。如果在长事务中出现DDL,则在执行DDL之前,数据库会自动将DDL之前的事务提交。Drop Table可以同时删除多个表,这些表可能存在,也可能不存在。如果删除列表中的某个表不存在,数据库仍会继续删除其它存在的表,但最终会输出一条表不存在的错误消息。如要删除t1,t2,t3,t4,t5,则t1,t2,t5表存在,t3,t4表不存在,则语句Drop Table t1,t2,t3,t4,t5;会删除t1,t2,t5,然后返回错误:ERROR 1051 (42S02): Unknown table ‘test.t3,test.t4’而在其它数据库中,比如PostgreSQL,就会将事务回滚,不会删除任何一张表。

Drop Table如何记录binlog?

在MySQL中,通过binlog进行主备之间的复制,保证主备节点间的数据一致,对于Drop table又有什么不同吗?仔细研究一下,还真的有很大的不同。MySQL支持两种binlog格式,statement和row,实践中还有一种是两者混合格式mixed。不同的binlog格式对SQL语句的binlog产生也会有不同的影响,尤其对Drop table来说,因为Drop table有很多之前提到的特殊之处,如可能同时删除多个不同类型的表,甚至删除不存在的表,因此在产生binlog时必须对这些不同类型的表或者不存在的表进行特殊的处理。

不存在表的处理

对于不存在表,实际上也没有表的定义, MySQL将其统一认作普通表,并按普通表来记录binlog。如Drop table if exists t1, t2,t3; 其中t1,t3存在,t2不存在;则会产生binlog如下所示:DROP TABLE IF EXISTS t1,t2,t3;

临时表的处理

此外影响最大的就是对临时表的处理,在statement格式下,所有对临时表的操作都要记录binlog,包括创建、删除及DML语句;但在row格式下,只有Drop table才会记录binlog,而对临时表的创建及DML语句是不记录binlog的。为什么会这样?通常情况下,主机的临时表在备机上是没有用的,临时表只在当前session中有效,即使将临时表同步到备机,当用户从主机切换到备机时,原来session已经中断,与session关联的临时表也会被清除,用户会重建session到新的主机。但在一些特殊情况下,还是需要将主机的临时表同步到备机的,比如主机上执行insert into t1 select * from temp1,其中t1是普通表,而temp1是临时表。当binlog格式为statement时,这条语句会被记录到binlog,然后同步到备机,在备机上replay,若备机之前没有将主机上的临时表同步过来,那这条语句的replay就会出现问题。因此在statement格式下,对临时表的操作如创建、删除及其它DML语句都必须记录binlog,然后同步到备机执行replay。但在row格式下,因为binlog中已经记录了实际的row,那么对临时表的创建、DML语句是不是记录binlog就不是那么重要了。这里有一点比较特殊,对临时表的删除还是要记录binlog。因为用户可以随时修改binlog的格式,若之前创建临时表时是statement格式,而创建成功后,又修改为row格式,若row格式下删除表不记录binlog,那么在备机上就会产生问题,创建了临时表,但却没有删除它。因此对drop table语句,无论binlog格式采用statement或是row格式,都会记录binlog。而对于创建临时表语句,只有statement格式会记录binlog,而在row格式下,不记录binlog。为防止row格式下在备机上replay时drop不存在的临时表,会将drop临时表的binlog中添加IF EXISTS,防止删除不存在的表replay失败。

不同类型表的处理

另外,drop table在产生binlog还有一个诡异的地方,通常一条SQL语句只会产生一个binlog event,占用一个gitd_executed,但drop table有可能会产生多个binlog event,并占用多个gtid_executed。如下示例:DROP TABLE t1, tmp1, i1, no1;其中t1为普通表,tmp1为innodb引擎的临时表,i1为MyISAM引擎的临时表,no1为不存在的表。则会产生3条binlog events,并且每个binlog events都有自己的gtid_executed。如下所示:
binlog.png

总结

由于历史原因,MySQL支持多种存储引擎,也支持多种复制模式,binlog的格式也从statement一种发展到现在的statement、row和mixed三种,为了兼容不同的存储引擎和不同的复制模式,在代码实现上做了很多折衷,这也要求我们要了解历史、了解未来,只有这样才能更好的使用、改进MySQL,为用户提供更好的云服务体验。

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

$
0
0

摘要

本期月报是SQL Server数据库备份技术系列文章的开篇,介绍三种常见的SQL Server备份方法的工作方式、使用T-SQL语句和使用SSMS IDE创建备份集三个层面,介绍SQL Server的三种常见备份的工作原理和使用方法。三种常见的备份包括:

数据库完全备份(Full Backup)

数据库日志备份(Transaction Log Backup)

数据库差异备份(Differential Backup)

备份的重要性

在开始分享之前,我们首先来看看数据库备份的重要性。进入DT时代,数据的价值越发体现,数据已经成为每个公司赖以生存的生命线,数据的重要性不言而喻,而公司绝大多数核心数据都存放在数据库里。数据库本身的灾难恢复(DR)能力是数据安全的最后一道防线,也是数据库从业者对数据安全底线的坚守。数据库中数据潜在的安全风险包括:硬件故障、恶意入侵、用户误操作、数据库损坏和自然灾害导致的数据损失等。在关系型数据库SQL Server中,数据库备份是灾难恢复的能力有力保证。

Full Backup

Full Backup(完全备份)是SQL Server所有备份类型中,最为简单、最基础的数据库备份方法,它提供了某个数据库在备份时间点的完整拷贝。但是,它仅支持还原到数据库备份成功结束的时间点,即不支持任意时间点还原操作。

Full Backup工作方式

以上是Full Backup是什么的解释,那么接下来,我们通过一张图和案例来解释Full Backup的工作原理。
01.png

这是一张某数据库的数据产生以及数据库备份在时间轴上的分布图,从左往右,我们可以分析如下:
7 P.m.:产生了数据#1

10 P.m.:数据库完全备份,备份文件中包含了#1

2 a.m.:产生了数据#2,目前数据包含#1,#2

6 a.m.:产生了数据#3,目前数据包含#1,#2,#3

10 a.m.:数据库完全备份,备份文件中包含#1,#2,#3

1 p.m.:产生了数据#4,目前数据包含#1,#2,#3,#4

5 p.m.:产生了数据#5,目前数据包含#1,#2,#3,#4,#5

8 p.m.:产生了数据#6,目前数据包含#1,#2,#3,#4,#5,#6

10 p.m.:数据库完全备份,备份文件中包含了数据#1,#2,#3,#4,#5,#6

从这张图和相应的解释分析来看,数据库完全备份工作原理应该是非常简单的,它就是数据库在备份时间点对所有数据的一个完整拷贝。当然在现实的生产环境中,事务的操作远比这个复杂,因此,在这个图里面有两个非常重要的点没有展示出来,那就是:

备份操作可能会导致I/O变慢:由于数据库备份是一个I/O密集型操作,所以在数据库备份过程中,可能会导致数据库的I/O操作变慢。

全备份过程中,数据库的事务日志不能够被截断:对于具有大事务频繁操作的数据库,可能会导致事务日志空间一直不停频繁增长,直到占满所有的磁盘剩余空间,这个场景在阿里云RDS SQL产品中有很多的客户都遇到过。其中之一解决方法就需要依赖于我们后面要谈到的事务日志备份技术。

T-SQL创建Full Backup

使用T-SQL语句来完成数据库的完全备份,使用BACKUP DATABASE语句即可,如下,对AdventureWorks2008R2数据库进行一个完全备份:

USE master
GO

BACKUP DATABASE [AdventureWorks2008R2] 
TO DISK = 'C:\Temp\AdventureWorks2008R2_20171112_FULL.bak' WITH COMPRESSION, INIT, STATS = 5;
GO

SSMS IDE创建Full Backup

除了使用T-SQL语句创建数据库的完全备份外,我们还可以使用SSMS IDE界面操作来完成,方法:
右键点击想要备份的数据库 => Tasks => Backup => 选择FULL Backup Type => 选择Disk 做为备份文件存储 => 点击Add 添加备份文件 => 选择你需要存储备份文件的目录 => 输入备份文件名,如下图两张图展示。
02.png

Back up Database设置界面

03.png

Transaction Log Backup

SQL Server数据库完全备份是数据库的完整拷贝,所以备份文件空间占用相对较大,加之可能会在备份过程中导致事务日志一直不断增长。为了解决这个问题,事务日志备份可以很好的解决这个问题,因为:事务日志备份记录了数据库从上一次日志备份到当前时间内的所有事务提交的数据变更,它可以配合数据库完全备份和差异备份(可选)来实现时间点的还原。当日志备份操作成功以后,事务日志文件会被截断,事务日志空间将会被重复循环利用,以此来解决完全备份过程中事务日志文件一致不停增长的问题,因此我们最好能够周期性对数据库进行事务日志备份,以此来控制事务日志文件的大小。但是这里需要有一个前提是数据库必须是FULL恢复模式,SIMPLE恢复模式的数据库不支持事务日志的备份,当然就无法实现时间点的还原。请使用下面的语句将数据库修改为FULL恢复模式,比如针对AdventureWorks2008R2数据库:

USE [master]
GO
ALTER DATABASE [AdventureWorks2008R2] SET RECOVERY FULL WITH NO_WAIT
GO

Transaction Log Backup工作方式

事务日志备份与数据完全备份工作方式截然不同,它不是数据库的一个完整拷贝,而是至上一次日志备份到当前时间内所有提交的事务数据变更。用一张图来解释事务日志备份的工作方式:

04.png

00:01:事务#1,#2,#3开始,未提交

00:02:事务#1,#2,#3成功提交;#4,#5,#6事务开始,未提交;这时备份事务日志;事务日志备份文件中仅包含已提交的#1,#2,#3的事务(图中的LSN 1-4,不包含#4)

00:04:由于在00:02做了事务日志备份,所以#1,#2,#3所占用的空间被回收;#4,#5,#6事务提交完成

00:05:事务#7已经提交成功;#8,#9,#10开始,但未提交;事务日志备份文件中包含#4,#5,#6,#7的事务(图中的LSN4-8,不包含#8)。

从这张图我们看到,每个事务日志备份文件中包含的是已经完成的事务变更,两次事务日志备份中存放的是完全不同的变更数据。而每一次事务日志备份成功以后,事务日志空间可以被成功回收,重复利用,达到了解决数据库完全备份过程中事务日志一致不断增长的问题。

T-SQL创建事务日志备份

使用T-SQL语句来创建事务日志的备份方法如下:

USE Master
GO

BACKUP LOG [AdventureWorks2008R2]
TO DISK = N'C:\temp\AdventureWorks2008R2_log_201711122201.trn' with compression,stats=1;
GO
BACKUP LOG [AdventureWorks2008R2]
TO DISK = N'C:\temp\AdventureWorks2008R2_log_201711122202.trn' with compression,stats=1;
GO
BACKUP LOG [AdventureWorks2008R2]
TO DISK = N'C:\temp\AdventureWorks2008R2_log_201711122203.trn' with compression,stats=1;
GO

SSMS IDE创建事务日志备份

使用SSMS IDE创建事务日志备份的方法:
右键点击想要创建事务日志备份的数据库 => Tasks => Backup => 选择Transaction Log Backup Type => 选择Disk 做为备份文件存储 => 点击Add 添加备份文件 => 选择你需要存储备份文件的目录 => 输入备份文件名,如下图展示:

05.png

事务日志备份链

由于数据库完全备份是时间点数据的完整拷贝,每个数据库完整备份相互独立,而多个事务日志备份是通过事务日志链条连接在一起,事务日志链起点于完全备份,SQL Server中的每一个事务日志备份文件都拥有自己的FirstLSN和LastLSN,FirstLSN用于指向前一个事务日志备份文件的LastLSN;而LastLSN指向下一个日志的FirstLSN,以此来建立这种链接关系。这种链接关系决定了事务日志备份文件还原的先后顺序。当然,如果其中任何一个事务日志备份文件丢失或者破坏,都会导致无法恢复整个事务日志链,仅可能恢复到你拥有的事务日志链条的最后一个。事务日志备份链条的关系如下图所示:

06.png

我们使用前面“T-SQL创建事务日志备份”创建的事务日志链,使用RESTORE HEADERONLY方法来查看事务日志链的关系:

USE Master
GO
RESTORE HEADERONLY FROM DISK = N'C:\temp\AdventureWorks2008R2_log_201711122201.trn';
RESTORE HEADERONLY FROM DISK = N'C:\temp\AdventureWorks2008R2_log_201711122202.trn';
RESTORE HEADERONLY FROM DISK = N'C:\temp\AdventureWorks2008R2_log_201711122203.trn';

查询结果如下:

07.png

从这个结果展示来看,事务日志备份文件AdventureWorks2008R2_log_201711122201的LastLSN指向了的AdventureWorks2008R2_log_201711122202的FirstLSN,而AdventureWorks2008R2_log_201711122202的LastLSN又指向了AdventureWorks2008R2_log_201711122203的FirstLSN,以此来建立了事务日志备份链条关系。假如AdventureWorks2008R2_log_201711122202的事务日志备份文件丢失或者损坏的话,数据库只能还原到AdventureWorks2008R2_log_201711122201所包含的所有事务行为。
这里有一个问题是:为了防止数据库事务日志一直不断的增长,而我们又不想每次都对数据库做完全备份,那么我们就必须对数据库事务日志做周期性的日志备份,比如:5分钟甚至更短,以此来降低数据丢失的风险,以此推算每天会产生24 * 12 = 288个事务日志备份,这样势必会导致事务日志恢复链条过长,拉长恢复时间,增大了数据库还原时间(RTO)。这个问题如何解决就是我们下面章节要分享到的差异备份技术。

Differential Backup

事务日志备份会导致数据库还原链条过长的问题,而差异备份就是来解决事务日志备份的这个问题的。差异备份是备份至上一次数据库全量备份以来的所有变更的数据页,所以差异备份相对于数据库完全备份而言往往数据空间占用会小很多。因此,备份的效率更高,还原的速度更快,可以大大提升我们灾难恢复的能力。

Differential Backup工作方式

我们还是从一张图来了解数据库差异备份的工作方式:

08.png

7 a.m.:数据包含#1

10 a.m.:数据库完全备份,备份文件中包含#1

1 p.m.:数据包含#1,#2,#3,#4

2 p.m.:数据库差异备份,备份文件中包含#2,#3,#4(上一次全备到目前的变更数据)

4 p.m.:数据包含#1,#2,…,#6

6 p.m.:数据库差异备份,备份文件中包含#2,#3,#4,#5,#6

8 p.m.:数据包含#1,#2,…,#8

10 p.m.:数据库完全备份,备份文件中包含#1,#2,…,#8

11 p.m.:产生新的数据#9,#10;数据包含#1,#2,…,#10

2 a.m.:数据库差异备份,备份文件中包含#9,#10

从这个差异备份的工作方式图,我们可以很清楚的看出差异备份的工作原理:它是备份继上一次完全备份以来的所有数据变更,所以它大大减少了备份日之链条的长度和缩小备份集的大小。

T-SQL创建差异备份

使用T-SQL语句创建差异备份的方法如下:

USE master
GO
BACKUP DATABASE [AdventureWorks2008R2] 
TO DISK = 'C:\Temp\AdventureWorks2008R2_20171112_diff.bak' WITH DIFFERENTIAL
GO

SSMS创建差异备份

使用SSMS IDE创建差异备份的方法:
右键点击想要创建事务日志备份的数据库 => Tasks => Backup => 选择Differential Backup Type => 选择Disk 做为备份文件存储 => 点击Add 添加备份文件 => 选择你需要存储备份文件的目录 => 输入备份文件名,如下图展示:

09.png

最后总结

本期月报分享了SQL Server三种常见的备份技术的工作方式和备份方法。数据库完全备份是数据库备份时间的一个完整拷贝;事务日志备份是上一次日志备份到当前时间的事务日志变更,它解决了数据库完全备份过程中事务日志一直增长的问题;差异备份上一次完全备份到当前时间的数据变更,它解决了事务日志备份链过长的问题。
将SQL Server这三种备份方式的工作方式,优缺点总结如下表格:

10.png

从这个表格,我们知道每种备份有其各自的优缺点,那么我们如何来制定我们的备份和还原策略以达到快速灾难恢复的能力呢?这个话题,我们将在下一期月报中进行分享。

参考

Full Backup工作方式图参考

Transaction Log Backup工作方式图参考

Differential Backup工作方式图参考

MySQL · 最佳实践 · 什么时候该升级内存规格

$
0
0

前言

在平时的工作中,会碰到用户想升级规格的case,有一些其实是没有必要的,这些通过优化设计或者改写SQL语句,或者加加索引可以达到不升级的效果,而有一些确实是需要升级规格的,比如今天讲的case。

追根溯源

查看表结构和索引

通过CloudDBA的SQL统计功能,发现SQL比较简单,也有索引,所以排除是这两方面设计的问题。

查看实例性能数据

image.png

innodb_buffer_pool命中率还不到99%,命中率不高的,而iowait>=2略微高,所以推测是命中率不高,导致数据在内存里换进换出导致。

image.png

系统层面io对列里面已经有少量的堆积;

查看内存内容

通过查看内存里面的数据和索引的大小,可以看到:

+--------+--------+---------+---------+---------+------------+---------+
| engine | TABLES | rows    | DATA    | idx     | total_size | idxfrac |
+--------+--------+---------+---------+---------+------------+---------+
| InnoDB |  11274 | 899.60M | 247.21G | 187.56G | 434.77G    |    0.76 |
+--------+--------+---------+---------+---------+------------+---------+

数据和索引已经将近440G,而BP却还是1G,更加可以印证上面的推测(数据在内存里面被频繁的换进换出)。
再来验证下:

image.png

过十多分钟再看,BP里面的内容已经不一样了:

image.png

查看实例是如何用的

通过上一步我们可以发现,整个实例的空间是400G+,qps,tps都很低,逻辑读不算高,为什么BP命中率会这么低呢?
通过

mysql>show global variables like "%buffer_pool%";

看到innodb_buffer_pool才1G,所有问题都已经明朗,那么如何解这个问题呢?

解决问题

我们再进一步看这个实例下面其实是有几十个库的,解决这个问题有两种方法:
1. 直接升级整个实例规格
2. 拆库

这么大的磁盘空间,又这么低的tps,所以我推荐第2种方法,拆分后其实也相当于变相地达到了升级实例规格的目的。把大实例拆成小实例后,再来看下对比:

image.png

结言

这个case是真正申请的内存规格小了些,所以这个是需要升级内存规格的。
一些小技巧,起到大作用,欢迎使用我们的经验沉淀下来的产品,您随叫随到的数据库专家CloudDBA

MySQL · 源码分析 · InnoDB LRU List刷脏改进之路

$
0
0

之前的一篇内核月报MySQL · 引擎特性 · InnoDB Buffer Pool中对InnoDB Buffer pool的整体进行了详细的介绍。文章已经提到了LRU List以及刷脏的工作原理。本篇文章着重从MySQL 5.7源码层面对LRU List刷脏的工作原理,以及Percona针对MySQL LRU Flush的一些性能问题所做的改进,进行一下分析。

在MySQL中,如果当前数据库需要操作的数据集比Buffer pool中的空闲页面大的话,当前Buffer pool中的数据页就必须进行脏页淘汰,以便腾出足够的空闲页面供当前的查询使用。如果数据库负载太高,对于空闲页面的需求超出了page cleaner的淘汰能力,这时候是否能够快速获取空闲页面,会直接影响到数据库的处理能力。我们将从下面三个阶段来看一下MySQL以及Percona对LRU List刷脏的改进过程。

众所周知,MySQL操作任何一个数据页面都需要读到Buffer pool进行才会进行操作。所以任何一个读写请求都需要从Buffer pool来获取所需页面。如果需要的页面已经存在于Buffer pool,那么直接利用当前页面进行操作就行。但是如果所需页面不在Buffer pool,比如UPDATE操作,那么就需要从Buffer pool中新申请空闲页面,将需要读取的数据放到Buffer pool中进行操作。那么官方MySQL 5.7.4之前的版本如何从buffer pool中获取一个页面呢?请看如下代码段:


buf_block_t*
buf_LRU_get_free_block(
/*===================*/
  buf_pool_t* buf_pool) /*!< in/out: buffer pool instance */
{
  buf_block_t*  block   = NULL;
  bool    freed   = false;
  ulint   n_iterations  = 0; 
  ulint   flush_failures  = 0; 
  bool    mon_value_was = false;
  bool    started_monitor = false;

  MONITOR_INC(MONITOR_LRU_GET_FREE_SEARCH);
loop:
  buf_pool_mutex_enter(buf_pool); // 这里需要对当前buf_pool使用mutex,存在锁竞争

  // 当前函数会检查一些非数据对象,比如AHI, lock 所占用的buf_pool是否太高并发出警告
  buf_LRU_check_size_of_non_data_objects(buf_pool);

  /* If there is a block in the free list, take it */
  block = buf_LRU_get_free_only(buf_pool);

  // 如果获取到了空闲页面,清零之后就直接使用。否则就需要进行LRU页面淘汰
  if (block != NULL) {

    buf_pool_mutex_exit(buf_pool);
    ut_ad(buf_pool_from_block(block) == buf_pool);
    memset(&block->page.zip, 0, sizeof block->page.zip);

    if (started_monitor) {
      srv_print_innodb_monitor =
        static_cast<my_bool>(mon_value_was);
    }

    block->skip_flush_check = false;
    block->page.flush_observer = NULL;
    return(block);
  }

  MONITOR_INC( MONITOR_LRU_GET_FREE_LOOPS );

  freed = false;
  /**
    这里会重复进行空闲页扫描,如果没有空闲页面,会根据LRU list对页面进行淘汰。
    这里设置buf_pool->try_LRU_scan是做了一个优化,如果当前用户线程扫描的时候
    发现没有空闲页面,那么其他用户线程就不需要进行同样的扫描。
  */

  if (buf_pool->try_LRU_scan || n_iterations > 0) {
    /* If no block was in the free list, search from the
    end of the LRU list and try to free a block there.
    If we are doing for the first time we'll scan only
    tail of the LRU list otherwise we scan the whole LRU
    list. */
    freed = buf_LRU_scan_and_free_block(
      buf_pool, n_iterations > 0);

    if (!freed && n_iterations == 0) {
      /* Tell other threads that there is no point
      in scanning the LRU list. This flag is set to
      TRUE again when we flush a batch from this
      buffer pool. */
      buf_pool->try_LRU_scan = FALSE;
    }
  }

  buf_pool_mutex_exit(buf_pool);

  if (freed) {
    goto loop;
  }

  if (n_iterations > 20
      && srv_buf_pool_old_size == srv_buf_pool_size) {
	// 如果循环获取空闲页的次数大于20次,系统将发出报警信息
      ...
}
 /* If we have scanned the whole LRU and still are unable to
  find a free block then we should sleep here to let the
  page_cleaner do an LRU batch for us. */

  if (!srv_read_only_mode) {
    os_event_set(buf_flush_event);
  }

  if (n_iterations > 1) {

    MONITOR_INC( MONITOR_LRU_GET_FREE_WAITS );
	// 这里每次循环释放空闲页面会间隔10ms
    os_thread_sleep(10000);
  }

  /* 如果buffer pool里面没有发现可以直接替换的页面(所谓直接替换的页面,
    是指页面没有被修改, 也没有别的线程进行引用,同时当前页已经被载入buffer pool),
    注意:上面的页面淘汰过程至少会尝试
    innodb_lru_scan_depth个页面。如果上面不存在可以淘汰的页面。那么系统将尝试淘汰一个
    脏页面(可替换页面或者已经被载入buffer pool的脏页面)。
  */
  if (!buf_flush_single_page_from_LRU(buf_pool)) {
    MONITOR_INC(MONITOR_LRU_SINGLE_FLUSH_FAILURE_COUNT);
    ++flush_failures;
  }

  srv_stats.buf_pool_wait_free.add(n_iterations, 1);

  n_iterations++;

  goto loop;
}



从上面获取一个空闲页的源码逻辑可以看出,buf_LRU_get_free_block会循环尝试去淘汰LRU list上的页面。每次循环都会去访问free list,查看是否有足够的空闲页面。如果没有将继续从LRU list去淘汰。这样的循环在负载比较高的情况下,会加剧对free list以及LRU list的mutex竞争。

MySQL空闲页面的获取依赖于page cleaner的刷新能力,如果page cleaner不能即时的刷新足够的空闲页面,那么系统就会使用上面的逻辑来为用户线程申请空闲页面。但如果让page cleaner加快刷新,又会导致频繁刷新脏数据,引发性能问题。 为了改善系统负载太高的情况下,page cleaner刷脏能力不足,进而用户线程调用LRU刷脏导致锁竞争加剧影响数据库性能,Percona对此进行了改善,引入独立的线程负责LRU list的刷脏。目的是为了让独立线程根据系统负载动态调整LRU的刷脏能力。由于LRU list的刷脏从page cleaner线程中脱离出来,调整LRU list的刷脏能力不再会影响到page cleaner。下面我们看一下相关的源码:

/**
  该函数会根据系统的负载情况,或者是buffer pool的空闲页面的情况来动态调整lru_manager_thread的  刷脏能力。
*/
static
void
lru_manager_adapt_sleep_time(
/*==============================*/
  ulint*  lru_sleep_time) /*!< in/out: desired page cleaner thread sleep
        time for LRU flushes  */
{
  /* 实际的空闲页 */
  ulint free_len = buf_get_total_free_list_length();
  /* 期望至少保持的空闲页 */
  ulint max_free_len = srv_LRU_scan_depth * srv_buf_pool_instances;

  /* 下面的逻辑会根据当前的空闲页面与期望的空闲页面之间的比对,
    来调整lru_manager_thread的刷脏频率
  */
  if (free_len < max_free_len / 100) {

    /* 实际的空闲页面小于期望的1%,系统会触使lru_manager_thread不断刷脏。*/
    *lru_sleep_time = 0;
  } else if (free_len > max_free_len / 5) {

    /* Free lists filled more than 20%, sleep a bit more */
    *lru_sleep_time += 50;
    if (*lru_sleep_time > srv_cleaner_max_lru_time) {
      *lru_sleep_time = srv_cleaner_max_lru_time;
    }
  } else if (free_len < max_free_len / 20 && *lru_sleep_time >= 50) {

    /* Free lists filled less than 5%, sleep a bit less */
    *lru_sleep_time -= 50;
  } else {

    /* Free lists filled between 5% and 20%, no change */
  }
}

extern "C" UNIV_INTERN
os_thread_ret_t
DECLARE_THREAD(buf_flush_lru_manager_thread)(
/*==========================================*/
  void* arg __attribute__((unused)))
      /*!< in: a dummy parameter required by
      os_thread_create */
{
  ulint next_loop_time = ut_time_ms() + 1000;
  ulint lru_sleep_time = srv_cleaner_max_lru_time;

#ifdef UNIV_PFS_THREAD
  pfs_register_thread(buf_lru_manager_thread_key);
#endif /* UNIV_PFS_THREAD */

#ifdef UNIV_DEBUG_THREAD_CREATION
  fprintf(stderr, "InnoDB: lru_manager thread running, id %lu\n",
    os_thread_pf(os_thread_get_curr_id()));
#endif /* UNIV_DEBUG_THREAD_CREATION */

  buf_lru_manager_is_active = true;
  /* On server shutdown, the LRU manager thread runs through cleanup
  phase to provide free pages for the master and purge threads.  */
  while (srv_shutdown_state == SRV_SHUTDOWN_NONE
         || srv_shutdown_state == SRV_SHUTDOWN_CLEANUP) {
    /* 根据系统负载情况,动态调整lru_manager_thread的工作频率 */
    lru_manager_sleep_if_needed(next_loop_time);

    lru_manager_adapt_sleep_time(&lru_sleep_time);

    next_loop_time = ut_time_ms() + lru_sleep_time;

    /**
      这里lru_manager_thread轮询每个buffer pool instances,尝试从LRU的尾部开始淘汰            innodb_lru_scan_depth个页面
    */
    buf_flush_LRU_tail();
  }

  buf_lru_manager_is_active = false;

  os_event_free(buf_lru_event);
  /* We count the number of threads in os_thread_exit(). A created
  thread should always use that to exit and not use return() to exit. */
  os_thread_exit(NULL);

  OS_THREAD_DUMMY_RETURN;
}

从上面的源码可以看到,LRU list的刷脏依赖于LRU_mangager_thread, 当然正常的page cleaner也会对LRU list进行刷脏。但是整个Buffer pool的所有instances都依赖于一个LRU list刷脏线程,负载比较高的情况下也很有可能成为瓶颈。

官方MySQL 5.7版本为了缓解单个page cleaner线程进行刷脏的压力,在5.7.4中引入了multiple page cleaner threads这个feature,用来增强刷脏速度,但是从下面的测试可以发现,即便是multiple page cleaner threads在高负载的情况下,还是会对系统性能有影响。下面的测试结果也显示了性能方面受到的影响。

5.7-mpc.png

就multiple page cleaner刷脏能力受到限制,主要是因为存在以下问题:
1) LRU List刷脏在先,Flush list的刷脏在后,但是是互斥的。也就是说在进Flush list刷脏的时候,LRU list不能继续去刷脏,必须等到下一个循环周期才能进行。
2) 另外一个问题就是,刷脏的时候,page cleaner coodinator会等待所有的page cleaner线程完成之后才会继续响应刷脏请求。这带来的问题就是如果某个buffer pool instance比较热的话,page cleaner就不能及时进行响应。

针对上面的问题,Percona改进了原来的单线程LRU list刷脏的方式,继续将LRU list独立于page cleaner threads并将LRU list单线程刷脏增加为多线程刷脏。page cleaner只负责flush list的刷脏,lru_manager_thread只负责LRU List刷脏。这样的分离,可以使得LRU list刷脏和Flush List刷脏并行执行。看一下修改之后的测试情况:

pc-mlf.png

下面用Multiple LRU list flush threads的源码patch简单介绍一下Percona所做的更改。

@@ -2922,26 +2876,12 @@ pc_flush_slot(void)
    }    
 
    if (!page_cleaner->is_running) {
-     slot->n_flushed_lru = 0;
      slot->n_flushed_list = 0; 
      goto finish_mutex;
    }    
 
    mutex_exit(&page_cleaner->mutex);
 
/* 这里的patch可以看出LRU list的刷脏从page cleaner线程里隔离开来 */
-   lru_tm = ut_time_ms();
-
-   /* Flush pages from end of LRU if required */
-   slot->n_flushed_lru = buf_flush_LRU_list(buf_pool);
-
-   lru_tm = ut_time_ms() - lru_tm;
-   lru_pass++;
-

@@ -1881,6 +1880,13 @@ innobase_start_or_create_for_mysql(void)
         NULL, NULL);
  }
/* 这里在MySQL启动的时候,会同时启动和Buffer pool instances同样数量的LRU list刷脏线程。 */
+ for (i = 0; i < srv_buf_pool_instances; i++) {
/* 这里每个LRU list线程负责自己对应的Buffer pool instance的LRU list刷脏 */
+   os_thread_create(buf_lru_manager, reinterpret_cast<void *>(i),
+        NULL);
+ }
+
+ buf_lru_manager_is_active = true;
+

综上所述,本篇文章主要从源码层面对Percona以及官方对于LRU list刷脏方面所做的改进进行了分析。Percona对于LRU list刷脏问题做了很大的贡献。从测试结果可以看到,如果负载较高,空闲页不足的情况下,Percona的改进起到了明显的作用。

MySQL · 特性分析 · MySQL 5.7 外部XA Replication实现及缺陷分析

$
0
0

MySQL 5.7 外部XA Replication实现及缺陷分析

MySQL 5.7增强了分布式事务的支持,解决了之前客户端退出或者服务器关闭后prepared的事务回滚和服务器宕机后binlog丢失的情况。

为了解决之前的问题,MySQL5.7将外部XA在binlog中的记录分成了两部分,使用两个GTID来记录。执行prepare的时候就记录一次binlog,执行commit/rollback再记录一次。由于XA是分成两部分记录,那么XA事务在binlog中就可能是交叉出现的。Slave端的SQL线程在apply的时候需要能够在这些不同事务间切换。

但MySQL XA Replication的实现只考虑了Innodb一种事务引擎的情况,当添加其他事务引擎的时候,原本的一些代码逻辑就会有问题。同时MySQL源码中也存在宕机导致主备不一致的缺陷。

MySQL 5.7 外部XA Replication源码剖析

Master写入

当执行 XA START ‘xid’后,内部xa_state进入XA_ACTIVE状态。

bool Sql_cmd_xa_start::trans_xa_start(THD *thd)
{
          xid_state->set_state(XID_STATE::XA_ACTIVE);

第一次记录DML操作的时候,通过下面代码可以看到,对普通事务在binlog的cache中第一个event记录’BEGIN’,如果是xa_state处于XA_ACTIVE状态就记录’XA START xid’,xid为序列化后的。

static int binlog_start_trans_and_stmt(THD *thd, Log_event *start_event)
{
  if (cache_data->is_binlog_empty())
  {
    if (is_transactional && xs->has_state(XID_STATE::XA_ACTIVE))
    {
      /*
        XA-prepare logging case.
        */
        qlen= sprintf(xa_start, "XA START %s", xs->get_xid()->serialize(buf));
        query= xa_start;
      }
      else
      {
        /*
        Regular transaction case.
        */
        query= begin;
      }

      Query_log_event qinfo(thd, query, qlen,
                          is_transactional, false, true, 0, true);
      if (cache_data->write_event(thd, &qinfo))
        DBUG_RETURN(1);

XA END xid的执行会将xa_state设置为XA_IDLE。

bool Sql_cmd_xa_end::trans_xa_end(THD *thd)
{
  xid_state->set_state(XID_STATE::XA_IDLE);

当XA PREPARE xid执行的时候,binlog_prepare会通过检查thd的xa_state是否处于XA_IDLE状态来决定是否记录binlog。如果在对应状态,就会调用MYSQL_BINLOG的commit函数,记录’XA PREPARE xid’,将之前cache的binlog写入到文件。

static int binlog_prepare(handlerton *hton, THD *thd, bool all)
{
  DBUG_RETURN(all && is_loggable_xa_prepare(thd) ?
              mysql_bin_log.commit(thd, true) : 0);


inline bool is_loggable_xa_prepare(THD *thd)
{
  return DBUG_EVALUATE_IF("simulate_commit_failure",
                          false,
                          thd->get_transaction()->xid_state()->
                          has_state(XID_STATE::XA_IDLE));

TC_LOG::enum_result MYSQL_BIN_LOG::commit(THD *thd, bool all)
{
      if (is_loggable_xa_prepare(thd))
    {
      XID_STATE *xs= thd->get_transaction()->xid_state();
      XA_prepare_log_event end_evt(thd, xs->get_xid(), one_phase);
      err= cache_mngr->trx_cache.finalize(thd, &end_evt, xs)
    }

当XA COMMIT/ROLLBACK xid执行时候,调用do_binlog_xa_commit_rollback记录’XA COMMIT/ROLLBACK xid’。

TC_LOG::enum_result MYSQL_BIN_LOG::commit(THD *thd, bool all)
{
  if (thd->lex->sql_command == SQLCOM_XA_COMMIT)
    do_binlog_xa_commit_rollback(thd, xs->get_xid(),
                                true)))

int MYSQL_BIN_LOG::rollback(THD *thd, bool all)
{
  if (thd->lex->sql_command == SQLCOM_XA_ROLLBACK)
    if ((error= do_binlog_xa_commit_rollback(thd, xs->get_xid(), false)))

由于XA PREPARE单独记录binlog,那么binlog中的events一个xa事务就可能是分隔开的。举个例子,session1中xid为’a’的分布式事务执行xa prepare后,session2中执行并提交了xid为’z’的事务,然后xid ‘a’才提交。我们可以看到binlog events中xid ‘z’的events在’a’的prepare和commit之间。

session1:
xa start 'a';
insert into t values(1);
xa end 'a';
xa prepare 'a';

session2:
xa start 'z';
insert into t values(2);
xa end 'z';
xa prepare 'z';
xa commit 'z';

session1:
xa commit 'a';


| mysql-bin.000008 |  250 | Gtid           |       324 |         298 | SET @@SESSION.GTID_NEXT= 'uuid:9'  |
| mysql-bin.000008 |  298 | Query          |       324 |         385 | XA START X'61',X'',1               |
| mysql-bin.000008 |  385 | Table_map      |       324 |         430 | table_id: 72 (test.t)              |
| mysql-bin.000008 |  430 | Write_rows_v1  |       324 |         476 | table_id: 72 flags: STMT_END_F     |
| mysql-bin.000008 |  476 | Query          |       324 |         561 | XA END X'61',X'',1                 |
| mysql-bin.000008 |  561 | XA_prepare     |       324 |         598 | XA PREPARE X'61',X'',1             |
| mysql-bin.000008 |  598 | Gtid           |       324 |         646 | SET @@SESSION.GTID_NEXT= 'uuid:10' |
| mysql-bin.000008 |  646 | Query          |       324 |         733 | XA START X'7a',X'',1               |
| mysql-bin.000008 |  733 | Table_map      |       324 |         778 | table_id: 72 (test.t)              |
| mysql-bin.000008 |  778 | Write_rows_v1  |       324 |         824 | table_id: 72 flags: STMT_END_F     |
| mysql-bin.000008 |  824 | Query          |       324 |         909 | XA END X'7a',X'',1                 |
| mysql-bin.000008 |  909 | XA_prepare     |       324 |         946 | XA PREPARE X'7a',X'',1             |
| mysql-bin.000008 |  946 | Gtid           |       324 |         994 | SET @@SESSION.GTID_NEXT= 'uuid:11' |
| mysql-bin.000008 |  994 | Query          |       324 |        1082 | XA COMMIT X'7a',X'',1              |
| mysql-bin.000008 | 1082 | Gtid           |       324 |        1130 | SET @@SESSION.GTID_NEXT= 'uuid:12' |
| mysql-bin.000008 | 1130 | Query          |       324 |        1218 | XA COMMIT X'61',X'',1              |

Slave 重放

由于XA事务在binlog中是会交叉出现的,Slave的SQL线程如果按照原本普通事务的方式重放,那么就会出现SQL线程中还存在处于prepared状态的事务,就开始处理下一个事务了,锁状态、事务状态等会错乱。所以SQL线程需要能够支持这种情况下不同事务间的切换。

SQL线程要做到能够在执行XA事务时切换到不同事务,需要做到server层保留原有xid的Transaction_ctx信息,引擎层也保留原有xid的事务信息。

server层保留原有xid的Transaction_ctx信息是通过在prepare的时候将thd中xid的Transaction_ctx信息从transacion_cache中detach掉,创建新的保留了XA事务信息的Transaction_ctx放入transaction_cache中。

bool Sql_cmd_xa_prepare::execute(THD *thd)
    !(st= applier_reset_xa_trans(thd)))

bool applier_reset_xa_trans(THD *thd)
    transaction_cache_detach(trn_ctx);

bool transaction_cache_detach(Transaction_ctx *transaction)
  res= create_and_insert_new_transaction(&xid, was_logged);

引擎层的实现并不是通过在prepare的时候创建新trx_t的来保存原有事务信息。而是在XA START的时候将原来thd中所有的engine ha_data单独保留起来,为XA事务创建新的。在XA PREPARE的时候,再将原来的reattach回来,将XA的从thd detach掉,解除XA和thd的关联。引擎层添加了新的接口replace_native_transaction_in_thd来支持上述操作。对于Slave的SQL线程,函数调用如下:

//engine 新添加的接口
struct handlerton
{
  void (*replace_native_transaction_in_thd)(THD *thd, void *new_trx_arg, void **ptr_trx_arg);

//XA START函数调用
bool Sql_cmd_xa_start::execute(THD *thd)
{
    thd->rpl_detach_engine_ha_data();

void THD::rpl_detach_engine_ha_data()
{
  rli->detach_engine_ha_data(this);

//每个Storage engine都调用detach_native_trx
void Relay_log_info::detach_engine_ha_data(THD *thd)
{
  plugin_foreach(thd, detach_native_trx,
                 MYSQL_STORAGE_ENGINE_PLUGIN, NULL);

my_bool detach_native_trx(THD *thd, plugin_ref plugin, void *unused)
{
  if (hton->replace_native_transaction_in_thd)
    hton->replace_native_transaction_in_thd(thd, NULL,
                                            thd_ha_data_backup(thd, hton));

//XA PREPARE函数调用
bool Sql_cmd_xa_prepare::execute(THD *thd)
{
  !(st= applier_reset_xa_trans(thd)))

bool applier_reset_xa_trans(THD *thd)
{
  attach_native_trx(thd);

//对事务涉及到的引擎调用reattach_engine_ha_data_to_thd。
static void attach_native_trx(THD *thd)
{
  if (ha_info)
  {
    for (; ha_info; ha_info= ha_info_next)
    {
      handlerton *hton= ha_info->ht();
      reattach_engine_ha_data_to_thd(thd, hton);
      ha_info_next= ha_info->next();
      ha_info->reset();
    }
  }

inline void reattach_engine_ha_data_to_thd(THD *thd, const struct handlerton *hton)
{
  if (hton->replace_native_transaction_in_thd)
    hton->replace_native_transaction_in_thd(thd, *trx_backup, NULL);

当XA COMMIT/ROLLBACK执行的时候,如果当前thd中没有对应的xid,就会从transaction_cache中查找对应xid的state信息,然后调用各个引擎的commit_by_xid/rollback_by_xid接口提交/回滚XA事务。

bool Sql_cmd_xa_commit::trans_xa_commit(THD *thd)
{
  if (!xid_state->has_same_xid(m_xid))
  {
        Transaction_ctx *transaction= transaction_cache_search(m_xid);
        ha_commit_or_rollback_by_xid(thd, m_xid, !res);

static void ha_commit_or_rollback_by_xid(THD *thd, XID *xid, bool commit)
{
  plugin_foreach(NULL, commit ? xacommit_handlerton : xarollback_handlerton,
                 MYSQL_STORAGE_ENGINE_PLUGIN, xid);

static my_bool xacommit_handlerton(THD *unused1, plugin_ref plugin, void *arg)
{
  if (hton->state == SHOW_OPTION_YES && hton->recover)
    hton->commit_by_xid(hton, (XID *)arg);

static my_bool xarollback_handlerton(THD *unused1, plugin_ref plugin, void *arg)
{
  if (hton->state == SHOW_OPTION_YES && hton->recover)
    hton->rollback_by_xid(hton, (XID *)arg);                               

由于XA COMMIT/XA ROLLBACK是单独作为一部分,这部分并没有原来XA事务涉及到库、表的信息,所以XA COMMIT在Slave端当slave-parallel-type为DATABASE时是无法并发执行的,在slave端强制设置mts_accessed_dbs为OVER_MAX_DBS_IN_EVENT_MTS使其串行执行。

bool Log_event::contains_partition_info(bool end_group_sets_max_dbs)
{
  case binary_log::QUERY_EVENT:
  {
    Query_log_event *qev= static_cast<Query_log_event*>(this);
    if ((ends_group() && end_group_sets_max_dbs) ||
      (qev->is_query_prefix_match(STRING_WITH_LEN("XA COMMIT")) ||
      qev->is_query_prefix_match(STRING_WITH_LEN("XA ROLLBACK"))))
      {
        res= true;
        qev->mts_accessed_dbs= OVER_MAX_DBS_IN_EVENT_MTS;
      }

MySQL5.7 外部XA Replication实现的缺陷分析

Prepare阶段可能导致主备不一致

MySQL中普通事务提交的时候,需要先在引擎中prepare,然后再写binlog,之后再做引擎commit。但在MySQL执行XA PREPARE的时候先写入了binlog,然后才做引擎的prepare。如果引擎在做prepare的时候失败或者服务器crash就会导致binlog和引擎不一致,主备进入不一致的状态。

在MySQL5.7中对模拟simulate_xa_failure_prepare的DEBUG情况做如下修改,使之模拟在Innodb引擎prepare的时候失败。

--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1460,10 +1460,12 @@ int ha_prepare(THD *thd)
       thd->status_var.ha_prepare_count++;
       if (ht->prepare)
       {
-        DBUG_EXECUTE_IF("simulate_xa_failure_prepare", {
-          ha_rollback_trans(thd, true);
-          DBUG_RETURN(1);
-        });
+        if (ht->db_type == DB_TYPE_INNODB) {
+          DBUG_EXECUTE_IF("simulate_xa_failure_prepare", {
+            ha_rollback_trans(thd, true);
+            DBUG_RETURN(1);
+          });
+        }
         if (ht->prepare(ht, thd, true))
         {
           ha_rollback_trans(thd, true);

然后运行下面的case,可以看到Master上的XA失败后被回滚。但由于这个时候已经写入了binlog events,导致Slave端执行了XA事务,留下一个处于prepared状态的XA事务。

replication.test:

--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,simulate_xa_failure_prepare';
--error ER_XA_RBROLLBACK
XA PREPARE 'x';
--echo #Master
XA RECOVER;

--sync_slave_with_master
connection slave;
--echo #Slave
XA RECOVER;


replication.result:

include/master-slave.inc
[connection master]
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,simulate_xa_failure_prepare';
XA PREPARE 'x';
ERROR XA100: XA_RBROLLBACK: Transaction branch was rolled back
#Master
XA RECOVER;
formatID  gtrid_length  bqual_length  data
#Slave
XA RECOVER;
formatID  gtrid_length  bqual_length  data
1 1 0 x

在MySQL5.7源码中,如果在binlog和InnoDB引擎都prepare之后是不是数据就安全了呢?我们在ha_prepare函数中while循环调用完所有引擎prepare函数之后添加如下DEBUG代码,可以控制在prepare调用结束后服务器crash掉。

--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1479,6 +1479,7 @@ int ha_prepare(THD *thd)
       }
       ha_info= ha_info->next();
     }
+    DBUG_EXECUTE_IF("crash_after_xa_prepare", DBUG_SUICIDE(););

     DBUG_ASSERT(thd->get_transaction()->xid_state()->
                 has_state(XID_STATE::XA_IDLE));

然后跑下面的testcase。可以看到即使所有引擎都prepare了,宕机重启后XA RECOVER还是还是没有能够找回之前prepare的事务。而且这个时候我们查看binlog文件可以看到binlog已经写成功,这也会导致主备不一致。很明显,应该是InnoDB引擎丢失了prepare的日志。那么是什么原因导致这个问题?感兴趣的同学可以查看int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit)和innobase中trx_prepare的代码,看process_flush_stage_queue和flush_logs和thd->durability_property的相关逻辑。这里不再展开详细叙述。

replication.test:

-- source include/have_log_bin.inc
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,crash_after_xa_prepare';
--exec echo "wait"> $MYSQLTEST_VARDIR/tmp/mysqld.1.expect
--error 2013
XA PREPARE 'x';
--source include/wait_until_disconnected.inc
--let $_expect_file_name= $MYSQLTEST_VARDIR/tmp/mysqld.1.expect
--source include/start_mysqld.inc
XA RECOVER;
show binlog events in 'mysql.000001';


replication.result:
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,crash_after_xa_prepare';
XA PREPARE 'x';
ERROR HY000: Lost connection to MySQL server during query
# restart
XA RECOVER;
formatID  gtrid_length  bqual_length  data
show binlog events in 'mysql.000001';
Log_name  Pos Event_type  Server_id End_log_pos Info
mysql.000001  4 Format_desc 1 123 Server ver: 5.7.19org-debug-log, Binlog ver: 4
mysql.000001  123 Previous_gtids  1 154
mysql.000001  154 Anonymous_Gtid  1 219 SET @@SESSION.GTID_NEXT= 'ANONYMOUS'
mysql.000001  219 Query 1 331 use `test`; CREATE TABLE ti (c1 INT) ENGINE=INNODB
mysql.000001  331 Anonymous_Gtid  1 396 SET @@SESSION.GTID_NEXT= 'ANONYMOUS'
mysql.000001  396 Query 1 483 XA START X'78',X'',1
mysql.000001  483 Table_map 1 528 table_id: 222 (test.ti)
mysql.000001  528 Write_rows  1 568 table_id: 222 flags: STMT_END_F
mysql.000001  568 Query 1 653 XA END X'78',X'',1
mysql.000001  653 XA_prepare  1 690 XA PREPARE X'78',X'',1

上面两个问题的修复,都可以通过先执行事务引擎的prepare操作,再调用binlog的prepare来解决。

不支持server中使用多个事务引擎

在上面实现分析中可以看到Slave在执行XA START的时候,由于这个时候并不知道该XA事务涉及到哪些引擎,所以对所有Storage engine引擎都调用了detach_native_trx。但在XA PREPARE的时候,源码中只对XA涉及到的引擎调用了reattach_engine_ha_data_to_thd。对于引擎可插拔的MySQL来说,当server中不止一个事务引擎,这里就会存在有的引擎原thd中的trx被detach后没有被reattach。

我们可以拿支持tokudb的percona server做对应实验。对DEBUG编译的server,执行下面replication的testcase。该case对TokuDB做一个完整的XA事务后,再向Innodb写入。运行该case,slave端会产生assert_fail的错误。因为TokuDB执行XA事务时,将Innodb的ha_data放入backup,但由于Innodb没有参与该XA事务,所以并没有reattach,导致gdb可以看到assert_fail处InnoDB的ha_ptr_backup不为NULL,不符合预期。

replication.test
--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
create table tk(c1 int) engine=tokudb;
create table ti(c1 int) engine=innodb;

xa start 'x';
insert into tk values(1);
xa end 'x';
xa prepare 'x';
xa commit 'x';

insert into ti values(2);

__assert_fail
thd->ha_data[ht_arg->slot].ha_ptr_backup == __null || (thd->get_transaction()->xid_state()-> has_state(XID_STATE::XA_ACTIVE))"

(gdb) p thd->ha_data[ht_arg->slot].ha_ptr_backup
$1 = (void *) 0x2b11e0401070

修复问题,可以在需要reattach_engine_ha_data_to_thd的代码处,对所有storage engine再次调用该操作。

不支持新接口的事务引擎重放新XA事务会出错

对于不支持reattach_engine_ha_data_to_thd的事务引擎实际是不支持重放MySQL5.7新XA方式生成的binlog的,但在源码中并没有合适禁止操作。这就会导致slave在apply的时候数据错乱。

继续使用支持tokudb的percona server做实验。由于TokuDB并没有实现reattach_engine_ha_data_to_thd接口,Slave在重放XA事务的时候,在TokuDB引擎中实际就在原本关联thd的trx上操作,并没有生成新的trx。这就会导致数据等信息错乱,可以看到下面的例子。session1做了一个XA事务,插入数值1,prepare后并没有提交。随后另一个session插入数值2,但在slave同步后,数值2无法查询到。在session1提交了XA事务,写入TokuDB的数值1、2才在slave端查询到。

replication.test:

--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
--echo #Master
create table tk(c1 int) engine=tokudb;
xa start 'x';
insert into tk values(1);
xa end 'x';
xa prepare 'x';
connect(m, localhost, root, , test, $MASTER_MYPORT);
insert into tk values(2);
select * from tk;

--sync_slave_with_master
connection slave;
--echo #Slave
select * from tk;

connection master;
--echo #Master
xa commit 'x';
select * from tk;

--sync_slave_with_master
connection slave;
--echo #Slave
select * from tk;

connection master;
drop table tk;



replication.result:

include/master-slave.inc
[connection master]
#Master
create table tk(c1 int) engine=tokudb;
xa start 'x';
insert into tk values(1);
xa end 'x';
xa prepare 'x';
insert into tk values(2);
select * from tk;
c1
2
#Slave
select * from tk;
c1
#Master
xa commit 'x';
select * from tk;
c1
1
2
#Slave
select * from tk;
c1
1
2
drop table tk;

修复该问题,需要对没有实现新接口的事务引擎在执行XA时候给与合适的禁止操作,同时需要支持新XA的事务引擎要实现reattach_engine_ha_data_to_thd接口。

PgSQL · 最佳实践 · 双十一数据运营平台订单Feed数据洪流实时分析方案

$
0
0

摘要

2017年的双十一又一次刷新了记录,交易创建峰值32.5万笔/秒、支付峰值25.6万笔/秒。而这样的交易和支付等记录,都会形成实时订单Feed数据流,汇入数据运营平台的主动服务系统中去。

数据运营平台的主动服务,根据这些合并后的数据,实时的进行分析,进行实时的舆情展示,实时的找出需要主动服务的对象等,实现一个智能化的服务运营平台。

通过阿里云RDS PostgreSQL和HybridDB for PGSQL实时分析方案:
- 承受住了几十万笔/s的写入吞吐并做数据清洗,是交易的数倍
- 实现分钟级延迟的实时分析,5张十亿级表关联秒级响应
- 实时发现交易异常,提升淘宝的用户体验。

业务背景

一个电商业务通常会涉及 商家、门店、物流、用户、支付渠道、贷款渠道、商品、平台、小二、广告商、厂家、分销商、店主、店员、监管员、税务、质检等等角色,这些对象的活动会产生大量的 浏览、订单、投诉、退款、纠纷等业务数据。

而任何一笔业务,都会涉及很多不同的业务系统。在这些业务系统中,为了定位问题、运营需要、分析需要或者其他需求,会在系统中设置埋点,记录用户的行为在业务系统中产生的日志,也叫FEED日志。比如订单系统、在业务系统中环环相扣,从购物车、下单、付款、发货,收货(还有纠纷、退款等等),一笔订单通常会产生若干相关联的记录。

每个环节产生的属性可能是不一样的,有可能有新的属性产生,也有可能变更已有的属性值。为了便于分析,通常有必要将订单在整个过程中产生的若干记录(若干属性),合并成一条记录(订单大宽表)。

数据运营平台的主动服务,根据这些合并后的数据,实时的进行分析,进行实时的舆情展示,实时的找出需要主动服务的对象等,实现一个智能化的服务运营平台。

难点

该项目不止业务逻辑复杂,实时性和性能要求都极高。具体的有:

  • 复杂查询的极限性能,比如5张过十亿的表关联
  • 实时性,要求分钟级以内的延迟
  • 高并发写入
  • 吞吐压力高达每秒几十万笔/s
  • 每个SQL分析的数据总是在TB级
  • 响应时间要求秒级、毫秒级

除了实时性的要求以外,在写入的过程中,还有数据的切换、合并和清理等动作。做过数据库或数据分析的会知道:单独要做到每秒80万/s吞吐的写入、切换、合并和清理并不算特别困难;单独要做到TB级数据的毫秒级分析也不算困难。

但要做到实时写入的同时提供分钟级延迟的秒级实时分析,并做合理的调度就没那么容易了。

方案

为支撑这样的业务需求,采用的方案图示如下:

HTAP.jpeg

其中:

  • RDS PostgreSQL 是阿里云基于开源关系型数据库PostgreSQL开发的云上版本
  • HybridDB for PostgreSQL是MPP架构的分布式分析型数据库,在多表关联、复杂查询、实时统计、圈人等诸多方面性能卓越,并支持JSON、GIS、HLL估值等多种独特的功能特性
  • OSS,是阿里云推出的海量、安全、低成本、高可靠的云存储服务,此处用作数据的离线存储
  • 最关键的,是实现RDS PostgreSQL和HybridDB for PostgreSQL 对离线存储OSS的透明化访问能力

在该方案中,多个PostgreSQL接受业务的写入,在每个RDS PostgreSQL中完成数据的清洗,然后以操作外部表(类似堆表)的方式,将清洗完的数据写入弹性存储OSS;而在写入完成后,HybridDB for PostgreSQL 也以操作外部表(类似堆表)的方式,从OSS中将数据并行加载到HybridDB中。在HybridDB中,实现几十、几百TB级数据的毫秒级查询。

在PostgreSQL中,创建一个外部表:

# 创建插件,每个库执行一次
create extension oss_fdw;

# 创建 server,每个OSS bucket创建一个
CREATE SERVER ossserver FOREIGN DATA WRAPPER oss_fdw OPTIONS 
     (host 'oss-cn-hangzhou-zmf.aliyuncs.com' , id 'xxx', key 'xxx',bucket 'mybucket');

# 创建 oss 外部表,每个需要操作的OSS对象对应一张表
CREATE FOREIGN TABLE ossexample 
    (date text, time text, volume int) 
     SERVER ossserver 
     OPTIONS ( filepath 'osstest/example.csv', delimiter ',' , 
        format 'csv', encoding 'utf8', PARSE_ERRORS '100');

这样即创建了映射到OSS对象的表,通过对ossexample的读写即是对OSS的读写。在数据写入”local_tbl”中后,执行以下SQL:

# 数据写入OSS
insert into ossexample  
  select date, time, volume)  from local_tbl  where date > '2017-09-20';  

表”local_tbl”中满足过滤条件的数据,即会写入OSS对应的对象”osstest/example.csv”中。

在HybridDB for PostgreSQL也用与此类似的方式读写OSS。整个过程,用户看到的只是一条条SQL。如下:

# 创建外部表,用于导出数据到OSS
create WRITABLE external table ossexample_exp 
        (date text, time text, volume int) 
        location('oss://oss-cn-hangzhou.aliyuncs.com
        prefix=osstest/exp/outfromhdb id=XXX
        key=XXX bucket=testbucket') FORMAT 'csv'
        DISTRIBUTED BY (date);

# 创建堆表,数据就装载到这张表中
create table example
        (date text, time text, volume int)
         DISTRIBUTED BY (date);

# 数据并行的从 ossexample 装载到 example 中
insert into example select * from ossexample;

该INSERT语句的执行,即会将”osstest/exp/outfromhdb” 文件中的数据,并行写入到表”example”中。其原理如下:

HybridDB读取OSS.jpeg

HybridDB 是分布式数据库,一个HybridDB for PostgreSQL集群中,有一个Master和多个Segment,Segment的个数可以横向扩充。Segment负责存储、分析数据,Master则是主入口接受查询请求并分发。

通过每个Segment并行从OSS上读取数据,整个集群可以达到相当高的吞吐能力,且这个能力随Segment个数而线性增加。

方案优势

上面的方案初看起来并不复杂,却解决了下面几个问题:

1.性能

融合了PostgreSQL超强的并发写入性能与HybridDB卓越的分析性能。

单个RDS PostgreSQL甚至可以支撑到百万级的写入; 而写入PostgreSQL后批量加载到HybridDB,使得PostgreSQL与HybridDB无缝衔接,利用MPP卓越的分析性能做到实时的毫秒级查询。

2.数据的搬运与清洗

在传统的分析领域,数据的搬运往往是比较重、且性能较差的一环,导致TP和AP距离较远,只能采用截然不同的方式和节奏。而如果是异构数据库的搬运,则痛苦指数再上台阶。

如果这些,都可以通过SQL来操作,数据的清洗和搬运最终都只是SQL的定义与执行,岂不美哉?

在上图中,RDS PostgreSQL 和 HybridDB for PostgreSQL都有直接读写OSS的能力,可以很容易地的串联起来。假以合理的调度和封装,可以以较低的成本实现原本需要很多工作量的功能。

3.冷热数据的统一

而借操作离线存储的能力,可以将冷数据放在OSS,热数据放在PostgreSQL或者HybridDB for PostgreSQL,可以通过SQL以相同的处理方式实现对冷热数据的统一处理。

4.动态调整资源

云生态的好处之一就是动态与弹性。RDS PostgreSQL的资源可以随时动态调整,而不影响任何的可用性,相当于给飞机在空中加油;而对HybridDB的扩容与缩容,则是秒级切换即可完成。OSS本身的弹性,也允许客户放多少的数据都可以。

因此,带来了如下几点优势:

  1. 相比于传统的数据分析方案,以SQL为统一的方式进行数据的管理,减少异构;
  2. 资源动态调度,降低成本
  3. 冷热数据界限模糊,直接互相访问
  4. TP、AP一体化
  5. RDS PostgreSQL的个数没有限制;HybridDB集群的数量没有限制

阿里云云数据库PostgreSQL

阿里云云数据库 PostgreSQL,基于号称“Most Advanced”的开源关系型数据库。在StackOverflow 2017开发者调查中,PostgreSQL可以说是“年度统计中开发者最爱和最想要的关系型数据库”

image.png

image.png

PostgreSQL的优势有以下几点:

  • 稳定

    PostgreSQL的代码质量是被很多人认可的,经常会有人笑称PG的开发者都是处女座。基本上,PG的一个大版本发布,经过三两个小版本就可以上生产,这是值得为人称道的一个地方。从PostgreSQL漂亮的commit log就可见一斑。

    而得益于PostgreSQL的多进程架构,一个连接的异常并不影响主进程和其他连接,从而带来不错的稳定性。

  • 性能

    我们内部有些性能上的数据,TPCC的性能测试显示PostgreSQL的性能与商业数据库基本在同一个层面上。

  • 丰富

    PostgreSQL的丰富性是最值得诉说的地方。因为太丰富了,以至于不知道该如何突出重点。这里只列举几个认为比较有意思的几点(查询、类型、功能):

    • 查询的丰富

      且不说HASH\Merge\NestLoop JOIN,还有递归、树形(connect by)、窗口、rollup\cube\grouping sets、物化视图、SQL标准等,还有各种全文检索、规则表达式、模糊查询、相似度等。在这些之外,最重要的是PostgreSQL强大的基于成本的优化器,结合并行执行(并行扫瞄、并行JOIN等)和多种成本因子,带来各种各样丰富灵活高效的查询支持。

    • 类型的丰富

      如高精度numeric, 浮点, 自增序列,货币,字节流,时间,日期,时间戳,布尔, 枚举,平面几何,立体几何,多维几何,地球,PostGIS,网络,比特流,全 文检索,UUID,XML,JSON,数组,复合类型,域类型,范围,树类型,化 学类型,基因序列,FDW, 大对象, 图像等。

      [PS: 这里的数组,可以让用户像操作JAVA中的数组一样操作数据库中的数据,如 item[0][1]即表示二维数组中的一个元素,而item可以作为表的一个字段。]

      或者,如果以上不够满足,你可以自定义自己的类型(create type),并且可以针对这些类型进行运算符重载,比如实现IP类型的加减乘除(其操作定义依赖于具体实现,意思是:你想让IP的加法是什么样子就是什么样子)。

    另外还有各种索引的类型,如btree, hash, gist, sp-gist, gin, brin , bloom , rum 索引等。你甚至可以为自己定义的类型定制特定的索引和索引扫瞄。

    • 功能的丰富

      PostgreSQL有一个无与伦比的特性——插件。其利用内核代码中的Hook,可以让你在不修改数据库内核代码的情况下,自主添加任意功能,如PostGIS、JSON、基因等,都是在插件中做了很多的自定义而又不影响任何内核代码从而满足丰富多样的需求。而PostgreSQL的插件,不计其数。

      FDW机制更让你可以在同一个PostgreSQL中像操作本地表一样访问其他数据源,如Hadoop、MySQL、Oracle、Mongo等,且不会占用PG的过多资源。比如我们团队开发的OSS_FDW就用于实现对OSS的读写。

至于其他的,举个简单的例子,PostgreSQL的DDL(如加减字段)是可以在事务中完成的 [PS: PostgreSQL是Catalog-Driven的,DDL的修改基本可以理解为一条记录的修改]。这一点,相信做业务的同学会有体会。

而在开源版本的基础上,阿里云云数据库PostgreSQL增加了HA、无缝扩缩容、自动备份、恢复与无感知切换、离线存储透明访问、诊断与优化等诸多功能,解除使用上的后顾之忧。更多的建议访问阿里云官网产品页(见文下参考)。

阿里云HybridDB for PostgreSQL

HybridDB for PostgreSQL是MPP架构的分布式分析型数据库,基于开源Greenplum,在多表关联、复杂查询、实时统计、圈人等诸多方面性能卓越。在此基础上,阿里云HybridDB for PostgreSQL提供JSON、GIS、HLL估值、备份恢复、异常自动化修复等多种独特的功能特性;并在METASCAN等方面做了诸多性能优化,相比开源版本有质的提升。

阿里云HybridDB for PostgreSQL有以下特点:

  • 实时分析

    支持SQL语法进行分布式GIS地理信息数据类型实时分析,协助物联网、互联网实现LBS位置服务统计;支持SQL语法进行分布式JSON、XML、模糊字符串等数据实时分析,助金融、政企行业实现报文数据处理及模糊文本统计。

  • 稳定可靠

    支持分布式ACID数据一致性,实现跨节点事务一致,所有数据双节点同步冗余,SLA保障99.9%可用性;分布式部署,计算单元、服务器、机柜三重防护,提高重要数据基础设施保障。

  • 简单易用

    丰富的OLAP SQL语法及函数支持,众多Oracle函数支持,业界流行的BI软件可直接联机使用;可与云数据库RDS(PostgreSQL/PPAS)实现数据通讯,实现OLTP+OLAP(HTAP)混合事务分析解决方案。

    支持分布式的SQL OLAP统计及窗口函数,支持分布式PL/pgSQL存储过程、触发器,实现数据库端分布式计算过程开发。

    符合国际OpenGIS标准的地理数据混合分析,通过单条SQL即可从海量数据中进行地理信息的分析,如:人流量、面积统计、行踪等分析。

  • 性能卓越

    支持行列混合存储,列存性能在OLAP分析时相比行存储可达100倍性能提升;支持高性能OSS并行数据导入,避免单通道导入的性能瓶颈。

    基于分布式大规模并行处理,随计算单元的添加线性扩展存储及计算能力,充分发挥每个计算单元的OLAP计算效能。

  • 灵活扩展

    按需进行计算单元,CPU、内存、存储空间的等比扩展,OLAP性能平滑上升致数百TB;支持透明的OSS数据操作,非在线分析的冷数据可灵活转存到OSS对象存储,数据存储容量无限扩展。

    通过MySQL数据库可以通过mysql2pgsql进行高性能数据导入,同时业界流行的ETL工具均可支持以HybridDB为目标的ETL数据导入。

    可将存储于OSS中的格式化文件作为数据源,通过外部表模式进行实时操作,使用标准SQL语法实现数据查询。

    支持数据从PostgreSQL/PPAS透明流入,持续增量无需编程处理,简化维护工作,数据入库后可再进行高性能内部数据建模及数据清洗。

  • 安全

    IP白名单配置,最多支持配置1000个允许连接RDS实例的服务器IP地址,从访问源进行直接的风险控制。

    DDOS防护, 在网络入口实时监测,当发现超大流量攻击时,对源IP进行清洗,清洗无效情况下可以直接拉进黑洞。

总结

利用阿里云的云生态,RDS PostgreSQL、HybridDB for PostgreSQL等一系列云服务,帮助企业打造智能的企业数据BI平台,HybridDB for PostgreSQL也企业大数据实时分析运算和存储的核心引擎。实现企业在云端从在线业务、到数据实时分析的业务数据闭环。

参考

MySQL · 引擎特性 · TokuDB hot-index机制

$
0
0

所谓hot-index就是指在构建索引的过程中不会阻塞查询数据,也不会阻塞修改数据(insert/update/delete)。在TokuDB的实现中只有使用“create index“方式创建索引的情况下才能使用hot-index;如果使用“alter table add index”是会阻塞更新操作的。

TokuDB handler的ha_tokudb::store_lock判断是create index方式创建索引并且只创建一个索引会把lock_type改成TL_WRITE_ALLOW_WRITE,这是一个特殊的锁类型,意思是在执行写操作的过程允许其他的写操作。

TokuDB提供了session变量tokudb_create_index_online,在线开启或者关闭hot-index功能。

THR_LOCK_DATA* *ha_tokudb::store_lock(
    THD* thd,
    THR_LOCK_DATA** to,
    enum thr_lock_type lock_type) {

    if (lock_type != TL_IGNORE && lock.type == TL_UNLOCK) {
        enum_sql_command sql_command = (enum_sql_command) thd_sql_command(thd);
        if (!thd->in_lock_tables) {
            if (sql_command == SQLCOM_CREATE_INDEX &&
                tokudb::sysvars::create_index_online(thd)) {
                // hot indexing
                share->_num_DBs_lock.lock_read();
                if (share->num_DBs == (table->s->keys + tokudb_test(hidden_primary_key))) {
                    lock_type = TL_WRITE_ALLOW_WRITE;
                }
                share->_num_DBs_lock.unlock();
            } else if ((lock_type >= TL_WRITE_CONCURRENT_INSERT &&
                        lock_type <= TL_WRITE) &&
                        sql_command != SQLCOM_TRUNCATE &&
                        !thd_tablespace_op(thd)) {
                // allow concurrent writes
                lock_type = TL_WRITE_ALLOW_WRITE;
            } else if (sql_command == SQLCOM_OPTIMIZE &&
                       lock_type == TL_READ_NO_INSERT) {
                // hot optimize table
                lock_type = TL_READ;
            }
        }
        lock.type = lock_type;
    }
}

代码逻辑如下图所示:

图片.png

ha_tokudb::tokudb_add_index是负责创建索引的方法。这个函数首先会判断如下条件:如果同时满足以下三个条件就会走到hot-index的逻辑,否则是传统的创建索引过程。
- 锁类型是TL_WRITE_ALLOW_WRITE
- 只创建一个索引
- 不是unique索引

int ha_tokudb::tokudb_add_index(
    TABLE* table_arg,
    KEY* key_info,
    uint num_of_keys,
    DB_TXN* txn,
    bool* inc_num_DBs,
    bool* modified_DBs) {

    bool use_hot_index = (lock.type == TL_WRITE_ALLOW_WRITE);

    creating_hot_index =
        use_hot_index && num_of_keys == 1 &&
        (key_info[0].flags & HA_NOSAME) == 0;

    if (use_hot_index && (share->num_DBs > curr_num_DBs)) {
        //
        // already have hot index in progress, get out
        //
        error = HA_ERR_INTERNAL_ERROR;
        goto cleanup;
    }

TokuDB目前只支持一个hot-index,也就是说同时只允许有一个hot-index在进行。如果hot-index过程中有新的创建索引操作会走传统的建索引逻辑。

传统的创建索引的方式是利用loader机制实现的,关于loader部分(点击这里跳转到原文)里面有比较详细的描述。

hot-index设计思路

对于是hot-index方式,首先通过调用db_env->create_index接口创建一个hot-index的handle,然后通过这个handle调用build方法构建索引数据,最后是调用close方法关闭handle。

大致过程如下:

图片.png

int ha_tokudb::tokudb_add_index(
    TABLE* table_arg,
    KEY* key_info,
    uint num_of_keys,
    DB_TXN* txn,
    bool* inc_num_DBs,
    bool* modified_DBs) {

   // 省略前面部分代码
    if (creating_hot_index) {
        share->num_DBs++;
        *inc_num_DBs = true;
        error = db_env->create_indexer(
            db_env,
            txn,
            &indexer,
            share->file,
            num_of_keys,
            &share->key_file[curr_num_DBs],
            mult_db_flags,
            indexer_flags);
        if (error) {
            goto cleanup;
        }

        error = indexer->set_poll_function(
            indexer, ha_tokudb::tokudb_add_index_poll, &lc);
        if (error) {
            goto cleanup;
        }

        error = indexer->set_error_callback(
            indexer, ha_tokudb::loader_add_index_err, &lc);
        if (error) {
            goto cleanup;
        }

        share->_num_DBs_lock.unlock();
        rw_lock_taken = false;

#ifdef HA_TOKUDB_HAS_THD_PROGRESS
        // initialize a one phase progress report.
        // incremental reports are done in the indexer's callback function.
        thd_progress_init(thd, 1);
#endif

        error = indexer->build(indexer);

        if (error) {
            goto cleanup;
        }

        share->_num_DBs_lock.lock_write();
        error = indexer->close(indexer);
        share->_num_DBs_lock.unlock();
        if (error) {
            goto cleanup;
        }
        indexer = NULL;
    }

build设计思想是通过遍历pk构造二级索引。在pk上创建一个le cursor,这个cursor特别之处是读取的是MVCC结构(即leafentry)而不是数据。Le cursor遍历的方向是从正无穷(最大的key值)向前访问,一直到负无穷(最小的key值)。通过Le cursor的key和value(从MVCC中得到的)构造二级索引的key;通过pk MVCC中的事务信息,构建二级索引的MVCC。

图片.png

创建indexer

indexer数据结构介绍

db_env->create_indexer其实就是toku_indexer_create_indexer,是在toku_env_create阶段设置的。
在create_indexer阶段,最主要工作就是初始化DB_INDEXER数据结构。

DB_INDEXER其实是一个接口类主要定义了build,close,abort等callback函数,其主体成员变量定义在struct __toku_indexer_internal里面。
DB_INDEXER定义如下:

typedef struct __toku_indexer DB_INDEXER;
struct __toku_indexer_internal;
struct __toku_indexer {
  struct __toku_indexer_internal *i;
  int (*set_error_callback)(DB_INDEXER *indexer, void (*error_cb)(DB *db, int i, int err, DBT *key, DBT *val, void *error_extra), void *error_extra); /* set the error callback */
  int (*set_poll_function)(DB_INDEXER *indexer, int (*poll_func)(void *extra, float progress), void *poll_extra);             /* set the polling function */
  int (*build)(DB_INDEXER *indexer);  /* build the indexes */
  int (*close)(DB_INDEXER *indexer);  /* finish indexing, free memory */
  int (*abort)(DB_INDEXER *indexer);  /* abort  indexing, free memory */
};

__toku_indexer_internal定义如下所示

图片.png

struct __toku_indexer_internal {
    DB_ENV *env;
    DB_TXN *txn;
    toku_mutex_t indexer_lock;
    toku_mutex_t indexer_estimate_lock;
    DBT position_estimate;
    DB *src_db;
    int N;
    DB **dest_dbs; /* [N] */
    uint32_t indexer_flags;
    void (*error_callback)(DB *db, int i, int err, DBT *key, DBT *val, void *error_extra);
    void *error_extra;
    int  (*poll_func)(void *poll_extra, float progress);
    void *poll_extra;
    uint64_t estimated_rows; // current estimate of table size
    uint64_t loop_mod;       // how often to call poll_func
    LE_CURSOR lec;
    FILENUM  *fnums; /* [N] */
    FILENUMS filenums;

    // undo state
    struct indexer_commit_keys commit_keys; // set of keys to commit
    DBT_ARRAY *hot_keys;
    DBT_ARRAY *hot_vals;

    // test functions
    int (*undo_do)(DB_INDEXER *indexer, DB *hotdb, DBT* key, ULEHANDLE ule);
    TOKUTXN_STATE (*test_xid_state)(DB_INDEXER *indexer, TXNID xid);
    void (*test_lock_key)(DB_INDEXER *indexer, TXNID xid, DB *hotdb, DBT *key);
    int (*test_delete_provisional)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, XIDS xids);
    int (*test_delete_committed)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, XIDS xids);
    int (*test_insert_provisional)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, DBT *hotval, XIDS xids);
    int (*test_insert_committed)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, DBT *hotval, XIDS xids);
    int (*test_commit_any)(DB_INDEXER *indexer, DB *db, DBT *key, XIDS xids);

    // test flags
    int test_only_flags;
};

db_env->create_index函数主要是初始化DB_INDEXER数据结构,这部分代码比较简单,请大家自行分析。

有一点需要提下,db_env->create_index调用toku_loader_create_loader创建一个dummy的索引。当build过程出错时,会放弃之前的所有操作,把索引重定向到那个dummy索引。这是利用loader redirect FT handle的功能,创建loader时指定LOADER_DISALLOW_PUTS标记。

构建indexer

构建indexer的函数是DB_INDEXER->build,其实调用的是build_index函数。

build_index主体是一个循环,每次去pk上读取一个key。前面提到过,访问pk是通过le cursor,每次向前访问,读取key和MVCC信息,在cursor callback把相应信息填到ule_prov_info数据结构里。le cursor的callback是le_cursor_callback,通过txn_manager得到第一个uncommitted txn信息,然后在通过那个txn的txn_child_manager得到其他的uncommitted txn信息。

在处理每个pk的key时,是受indexer->i->indexer_lock互斥锁保护的,保证build过程跟用户的dml语句互斥。build的过程还获取了multi_operation_lock读写锁的读锁。在处理当前pk值,是不允许dml和checkpoint的。对于每个pk的<key,mvcc>二元组,调用indexer_undo_do函数来构建二级索引的key和mvcc信息。下面函数中hot_keys和hot_vals是生成二级索引key和val的buffer。

struct ule_prov_info {
    // these are pointers to the allocated leafentry and ule needed to calculate
    // provisional info. we only borrow them - whoever created the provisional info
    // is responsible for cleaning up the leafentry and ule when done.
    LEAFENTRY le;    //packed MVCC info
    ULEHANDLE ule;   //unpacked MVCC info
    void* key;        // key
    uint32_t keylen;  // key length

    // provisional txn info for the ule
    uint32_t num_provisional;  // uncommitted txn number
    uint32_t num_committed;    // committed txn number
    TXNID *prov_ids;           // each txnid for uncommitted txn
    TOKUTXN *prov_txns;        // each txn for uncommited txn
    TOKUTXN_STATE *prov_states; // each txn state for uncommitted txn
};

static int
build_index(DB_INDEXER *indexer) {
    int result = 0;

    bool done = false;
    for (uint64_t loop_count = 0; !done; loop_count++) {

        toku_indexer_lock(indexer);
        // grab the multi operation lock because we will be injecting messages
        // grab it here because we must hold it before
        // trying to pin any live transactions, as discovered by #5775
        toku_multi_operation_client_lock();

        // grab the next leaf entry and get its provisional info. we'll
        // need the provisional info for the undo-do algorithm, and we get
        // it here so it can be read atomically with respect to txn commit
        // and abort. the atomicity comes from the root-to-leaf path pinned
        // by the query and in the getf callback function
        //
        // this allocates space for the prov info, so we have to destroy it
        // when we're done.
        struct ule_prov_info prov_info;
        memset(&prov_info, 0, sizeof(prov_info));
        result = get_next_ule_with_prov_info(indexer, &prov_info);

        if (result != 0) {
            invariant(prov_info.ule == NULL);
            done = true;
            if (result == DB_NOTFOUND) {
                result = 0;  // all done, normal way to exit loop successfully
            }
        }
        else {
            invariant(prov_info.le);
            invariant(prov_info.ule);
            for (int which_db = 0; (which_db < indexer->i->N) && (result == 0); which_db++) {
                DB *db = indexer->i->dest_dbs[which_db];
                DBT_ARRAY *hot_keys = &indexer->i->hot_keys[which_db];
                DBT_ARRAY *hot_vals = &indexer->i->hot_vals[which_db];
                result = indexer_undo_do(indexer, db, &prov_info, hot_keys, hot_vals);
                if ((result != 0) && (indexer->i->error_callback != NULL)) {
                    // grab the key and call the error callback
                    DBT key; toku_init_dbt_flags(&key, DB_DBT_REALLOC);
                    toku_dbt_set(prov_info.keylen, prov_info.key, &key, NULL);
                    indexer->i->error_callback(db, which_db, result, &key, NULL, indexer->i->error_extra);
                    toku_destroy_dbt(&key);
                }
            }
            // the leafentry and ule are not owned by the prov_info,
            // and are still our responsibility to free
            toku_free(prov_info.le);
            toku_free(prov_info.key);
            toku_ule_free(prov_info.ule);
        }

        toku_multi_operation_client_unlock();
        toku_indexer_unlock(indexer);
        ule_prov_info_destroy(&prov_info);

        if (result == 0) {
            result = maybe_call_poll_func(indexer, loop_count);
        }
        if (result != 0) {
            done = true;
        }
    }
}

写了这么多都是framework,汗:(

indexer_undo_do函数才是build灵魂,每次调用生成二级索引的key和MVCC信息。传入参数是ule_prov_info,封装了pk的key和mvcc信息。

indexer_undo_do实现很直接,首先调用 indexer_undo_do_committed处理已提交事务对二级索引的修改,这些修改在pk上是提交的,那么在二级索引上面也一定是提交的。反复修改同一个pk会导致产生多个二级索引的key值。在pk上的体现是新值override老值;而在二级索引上就是要删老值,加新值。这也就是undo_do的意思啦。

处理committed事务时,每次处理完成都要记住新添加的二级索引的key值。最后对每个key发一个FT_COMMIT_ANY消息,整理MVCC结构,DB_INDEXER->commit_keys就是记录已提交二级索引key的,是一个数组。

int
indexer_undo_do(DB_INDEXER *indexer, DB *hotdb, struct ule_prov_info *prov_info, DBT_ARRAY *hot_keys, DBT_ARRAY *hot_vals) {
    int result = indexer_undo_do_committed(indexer, hotdb, prov_info, hot_keys, hot_vals);
    if (result == 0) {
        result = indexer_undo_do_provisional(indexer, hotdb, prov_info, hot_keys, hot_vals);
    }
    if (indexer->i->test_only_flags == INDEXER_TEST_ONLY_ERROR_CALLBACK)  {
        result = EINVAL;
    }

    return result;
}

indexer_undo_do_committed函数相对简单,请大家自行分析。

下面一起看一下indexer_undo_do_provisional函数。如果num_provisional等于0,没有正在进行中的事务,直接返回。

然后依次查看每个provisional事务,uxr表示当前provisional事务的信息,包括value,txnid和delete标记。this_xid表示当前事务的txnid;this_xid_state表示当前事务的状态。

如果当前事务状态是TOKUTXN_ABORTING,啥也不用干,省得以后在root txn commit时还要再去做rollback。

条件xrindex == num_committed表示当前事务的root txn,一定把它加到xids里面;否则,意味着是子事务,只有当它处于TOKUTXN_LIVE状态时加到xids里面。xids数组是为了往FT发msg用的,表示msg所处txn上下文。

对于provisional事务,也有undo和do阶段。针对mvcc里面的nested txn,undo阶段删除old image对应的二级索引key,do阶段添加new image对应的二级索引key。这部分跟indexer_undo_do_committed类似。

只不过indexer_undo_do_provisional需要考虑最外层provisional事务(当前alive事务的root txn)的状态。

如果是TOKUTXN_LIVE或者TOKUTXN_PREPARING表名root txn正在进行中,模拟用户写索引的行为,直接调用toku_ft_maybe_delete(删除old key)或者toku_ft_maybe_insert(添加new key),这个过程是需要记undo log和redo log的,因为pk上这个事务正在进行中。

如果最外层provisional事务(当前alive事务的root txn)的状态是TOKUTXN_COMMITTING或者TOKUTXN_RETIRED表示pk上这个事务准备提交或者已经提交,直接删除old key或者添加new key,不需要记undo log和redo log,因为pk预期是提交的。

对应每个pk上面是提交的key,也需要记录下来,在结束前对每个key发FT_COMMIT_ANY消息整理MVCC结构。

release_txns函数unpin每个活跃的provisional事务,pin的过程是在toku_txn_pin_live_txn_unlocked做的;pin的目的是防止txn commit或者abort。

static int
indexer_undo_do_provisional(DB_INDEXER *indexer, DB *hotdb, struct ule_prov_info *prov_info, DBT_ARRAY *hot_keys, DBT_ARRAY *hot_vals) {
    int result = 0;
    indexer_commit_keys_set_empty(&indexer->i->commit_keys);
    ULEHANDLE ule = prov_info->ule;

    // init the xids to the root xid
    XIDS xids = toku_xids_get_root_xids();

    uint32_t num_provisional = prov_info->num_provisional;
    uint32_t num_committed = prov_info->num_committed;
    TXNID *prov_ids = prov_info->prov_ids;
    TOKUTXN *prov_txns = prov_info->prov_txns;
    TOKUTXN_STATE *prov_states = prov_info->prov_states;

    // nothing to do if there's nothing provisional
    if (num_provisional == 0) {
        goto exit;
    }

    TXNID outermost_xid_state;
    outermost_xid_state = prov_states[0];

    // scan the provisional stack from the outermost to the innermost transaction record
    TOKUTXN curr_txn;
    curr_txn = NULL;
    for (uint64_t xrindex = num_committed; xrindex < num_committed + num_provisional; xrindex++) {

        // get the ith transaction record
        UXRHANDLE uxr = ule_get_uxr(ule, xrindex);

        TXNID this_xid = uxr_get_txnid(uxr);
        TOKUTXN_STATE this_xid_state = prov_states[xrindex - num_committed];

        if (this_xid_state == TOKUTXN_ABORTING) {
            break;         // nothing to do once we reach a transaction that is aborting
        }

        if (xrindex == num_committed) { // if this is the outermost xr
            result = indexer_set_xid(indexer, this_xid, &xids);    // always add the outermost xid to the XIDS list
            curr_txn = prov_txns[xrindex - num_committed];
        } else {
            switch (this_xid_state) {
            case TOKUTXN_LIVE:
                result = indexer_append_xid(indexer, this_xid, &xids); // append a live xid to the XIDS list
                curr_txn = prov_txns[xrindex - num_committed];
                if (!indexer->i->test_xid_state) {
                    assert(curr_txn);
                }
                break;
            case TOKUTXN_PREPARING:
                assert(0); // not allowed
            case TOKUTXN_COMMITTING:
            case TOKUTXN_ABORTING:
            case TOKUTXN_RETIRED:
                break; // nothing to do
            }
        }
        if (result != 0)
            break;

        if (outermost_xid_state != TOKUTXN_LIVE && xrindex > num_committed) {
            // If the outermost is not live, then the inner state must be retired.  That's the way that the txn API works.
            assert(this_xid_state == TOKUTXN_RETIRED);
        }

        if (uxr_is_placeholder(uxr)) {
            continue;         // skip placeholders
        }
        // undo
        uint64_t prev_xrindex;
        bool prev_xrindex_found = indexer_find_prev_xr(indexer, ule, xrindex, &prev_xrindex);
        if (prev_xrindex_found) {
            UXRHANDLE prevuxr = ule_get_uxr(ule, prev_xrindex);
            if (uxr_is_delete(prevuxr)) {
                ; // do nothing
            } else if (uxr_is_insert(prevuxr)) {
                // generate the hot delete key
                result = indexer_generate_hot_keys_vals(indexer, hotdb, prov_info, prevuxr, hot_keys, NULL);
                if (result == 0) {
                    paranoid_invariant(hot_keys->size <= hot_keys->capacity);
                    for (uint32_t i = 0; i < hot_keys->size; i++) {
                        DBT *hotkey = &hot_keys->dbts[i];

                        // send the delete message
                        switch (outermost_xid_state) {
                        case TOKUTXN_LIVE:
                        case TOKUTXN_PREPARING:
                            invariant(this_xid_state != TOKUTXN_ABORTING);
                            invariant(!curr_txn || toku_txn_get_state(curr_txn) == TOKUTXN_LIVE || toku_txn_get_state(curr_txn) == TOKUTXN_PREPARING);
                            result = indexer_ft_delete_provisional(indexer, hotdb, hotkey, xids, curr_txn);
                            if (result == 0) {
                                indexer_lock_key(indexer, hotdb, hotkey, prov_ids[0], curr_txn);
                            }
                            break;
                        case TOKUTXN_COMMITTING:
                        case TOKUTXN_RETIRED:
                            result = indexer_ft_delete_committed(indexer, hotdb, hotkey, xids);
                            if (result == 0)
                                indexer_commit_keys_add(&indexer->i->commit_keys, hotkey->size, hotkey->data);
                            break;
                        case TOKUTXN_ABORTING: // can not happen since we stop processing the leaf entry if the outer most xr is aborting
                            assert(0);
                        }
                    }
                }
            } else
                assert(0);
        }
        if (result != 0)
            break;

        // do
        if (uxr_is_delete(uxr)) {
            ; // do nothing
        } else if (uxr_is_insert(uxr)) {
            // generate the hot insert key and val
            result = indexer_generate_hot_keys_vals(indexer, hotdb, prov_info, uxr, hot_keys, hot_vals);
            if (result == 0) {
                paranoid_invariant(hot_keys->size == hot_vals->size);
                paranoid_invariant(hot_keys->size <= hot_keys->capacity);
                paranoid_invariant(hot_vals->size <= hot_vals->capacity);
                for (uint32_t i = 0; i < hot_keys->size; i++) {
                    DBT *hotkey = &hot_keys->dbts[i];
                    DBT *hotval = &hot_vals->dbts[i];

                    // send the insert message
                    switch (outermost_xid_state) {
                    case TOKUTXN_LIVE:
                    case TOKUTXN_PREPARING:
                        assert(this_xid_state != TOKUTXN_ABORTING);
                        invariant(!curr_txn || toku_txn_get_state(curr_txn) == TOKUTXN_LIVE || toku_txn_get_state(curr_txn) == TOKUTXN_PREPARING);
                        result = indexer_ft_insert_provisional(indexer, hotdb, hotkey, hotval, xids, curr_txn);
                        if (result == 0) {
                            indexer_lock_key(indexer, hotdb, hotkey, prov_ids[0], prov_txns[0]);
                        }
                        break;
                    case TOKUTXN_COMMITTING:
                    case TOKUTXN_RETIRED:
                        result = indexer_ft_insert_committed(indexer, hotdb, hotkey, hotval, xids);
                        // no need to do this because we do implicit commits on inserts
                        if (0 && result == 0)
                            indexer_commit_keys_add(&indexer->i->commit_keys, hotkey->size, hotkey->data);
                        break;
                    case TOKUTXN_ABORTING: // can not happen since we stop processing the leaf entry if the outer most xr is aborting
                        assert(0);
                    }
                }
            }
        } else
            assert(0);

        if (result != 0)
            break;
    }

    // send commits if the outermost provisional transaction is committed
    for (int i = 0; result == 0 && i < indexer_commit_keys_valid(&indexer->i->commit_keys); i++) {
        result = indexer_ft_commit(indexer, hotdb, &indexer->i->commit_keys.keys[i], xids);
    }

    // be careful with this in the future. Right now, only exit path
    // is BEFORE we call fill_prov_info, so this happens before exit
    // If in the future we add a way to exit after fill_prov_info,
    // then this will need to be handled below exit
    release_txns(ule, prov_states, prov_txns, indexer);
exit:
    toku_xids_destroy(&xids);
    return result;
}

关闭indexer

这部分就是关闭handle,释放内存。由于篇幅有限,本文不深入讨论。

与dml互斥

每个更新操作,包括insert,update和delete都要比较待处理的二级索引key是否落在已经build的部分。如果是,其处理方式跟通常的一样,直接调用db接口;否则留给hot-index来处理。

判断key是否落在已build好的部分是通过toku_indexer_should_insert_key函数比较le cursor正在处理的key和pk的key来实现的。为了避免访问le cursor的竞态,每次比较都是在indexer->i->indexer_lock保护下进行。直觉告诉我们,这个操作会影响性能,并发写可能会在indexer->i->indexer_lock上排队。

hot-index维护了le cursor大致位置indexer->i->position_estimate,这个位置是延迟更新的。每次访问le cursor比较后更新这个位置。那么,比它大的key一定落在build好的部分的。

与indexer->i->position_estimate比较的过程是不需要获取indexer->i->indexer_lock的,利用它可以做个快算判断,减少indexer->i->indexer_lock争抢。

其实,indexer->i->position_estimate更新是受indexer->i->indexer_estimate_lock保护的,这也可以算是锁拆分优化。

需要注意的是indexer->i->position_estimate和le cursor正在处理的key(更精确)都是指pk上的位置。

// a shortcut call
//
// a cheap(er) call to see if a key must be inserted
// into the DB. If true, then we know we have to insert.
// If false, then we don't know, and have to check again
// after grabbing the indexer lock
bool
toku_indexer_may_insert(DB_INDEXER* indexer, const DBT* key) {
    bool may_insert = false;
    toku_mutex_lock(&indexer->i->indexer_estimate_lock);

    // if we have no position estimate, we can't tell, so return false
    if (indexer->i->position_estimate.data == nullptr) {
        may_insert = false;
    } else {
        DB *db = indexer->i->src_db;
        const toku::comparator &cmp = toku_ft_get_comparator(db->i->ft_handle);
        int c = cmp(&indexer->i->position_estimate, key);

        // if key > position_estimate, then we know the indexer cursor
        // is past key, and we can safely say that associated values of
        // key must be inserted into the indexer's db
        may_insert = c < 0;
    }

    toku_mutex_unlock(&indexer->i->indexer_estimate_lock);
    return may_insert;
}

到这里,hot-index部分就介绍完了。代码看着复杂,但比起loader来要简单不少。


MySQL · 最佳实践 · 分区表基本类型

$
0
0

MySQL分区表概述

随着MySQL越来越流行,Mysql里面的保存的数据也越来越大。在日常的工作中,我们经常遇到一张表里面保存了上亿甚至过十亿的记录。这些表里面保存了大量的历史记录。
对于这些历史数据的清理是一个非常头疼事情,由于所有的数据都一个普通的表里。所以只能是启用一个或多个带where条件的delete语句去删除(一般where条件是时间)。
这对数据库的造成了很大压力。即使我们把这些删除了,但底层的数据文件并没有变小。面对这类问题,最有效的方法就是在使用分区表。最常见的分区方法就是按照时间进行分区。
分区一个最大的优点就是可以非常高效的进行历史数据的清理。

分区类型

目前MySQL支持范围分区(RANGE),列表分区(LIST),哈希分区(HASH)以及KEY分区四种。下面我们逐一介绍每种分区:

RANGE分区

基于属于一个给定连续区间的列值,把多行分配给分区。最常见的是基于时间字段. 基于分区的列最好是整型,如果日期型的可以使用函数转换为整型。本例中使用to_days函数

CREATE TABLE my_range_datetime(
    id INT,
    hiredate DATETIME
) 
PARTITION BY RANGE (TO_DAYS(hiredate) ) (
    PARTITION p1 VALUES LESS THAN ( TO_DAYS('20171202') ),
    PARTITION p2 VALUES LESS THAN ( TO_DAYS('20171203') ),
    PARTITION p3 VALUES LESS THAN ( TO_DAYS('20171204') ),
    PARTITION p4 VALUES LESS THAN ( TO_DAYS('20171205') ),
    PARTITION p5 VALUES LESS THAN ( TO_DAYS('20171206') ),
    PARTITION p6 VALUES LESS THAN ( TO_DAYS('20171207') ),
    PARTITION p7 VALUES LESS THAN ( TO_DAYS('20171208') ),
    PARTITION p8 VALUES LESS THAN ( TO_DAYS('20171209') ),
    PARTITION p9 VALUES LESS THAN ( TO_DAYS('20171210') ),
    PARTITION p10 VALUES LESS THAN ( TO_DAYS('20171211') ),
    PARTITION p11 VALUES LESS THAN (MAXVALUE) 
);

p11是一个默认分区,所有大于20171211的记录都会在这个分区。MAXVALUE是一个无穷大的值。p11是一个可选分区。如果在定义表的没有指定的这个分区,当我们插入大于20171211的数据的时候,会收到一个错误。

我们在执行查询的时候,必须带上分区字段。这样可以使用分区剪裁功能

mysql> insert into my_range_datetime select * from test;                                                                    
Query OK, 1000000 rows affected (8.15 sec)
Records: 1000000  Duplicates: 0  Warnings: 0

mysql> explain partitions select * from my_range_datetime where hiredate >= '20171207124503' and hiredate<='20171210111230'; 
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table             | partitions   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | my_range_datetime | p7,p8,p9,p10 | ALL  | NULL          | NULL | NULL    | NULL | 400061 | Using where |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
1 row in set (0.03 sec)

注意执行计划中的partitions的内容,只查询了p7,p8,p9,p10三个分区,由此来看,使用to_days函数确实可以实现分区裁剪。

上面是基于datetime的,如果是timestamp类型,我们遇到上面问题呢?

事实上,MySQL提供了一种基于UNIX_TIMESTAMP函数的RANGE分区方案,而且,只能使用UNIX_TIMESTAMP函数,如果使用其它函数,譬如to_days,会报如下错误:“ERROR 1486 (HY000): Constant, random or timezone-dependent expressions in (sub)partitioning function are not allowed”。

而且官方文档中也提到“Any other expressions involving TIMESTAMP values are not permitted. (See Bug #42849.)”。

下面来测试一下基于UNIX_TIMESTAMP函数的RANGE分区方案,看其能否实现分区裁剪。

针对TIMESTAMP的分区方案

创表语句如下:

CREATE TABLE my_range_timestamp (
    id INT,
    hiredate TIMESTAMP
)
PARTITION BY RANGE ( UNIX_TIMESTAMP(hiredate) ) (
    PARTITION p1 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-02 00:00:00') ),
    PARTITION p2 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-03 00:00:00') ),
    PARTITION p3 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-04 00:00:00') ),
    PARTITION p4 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-05 00:00:00') ),
    PARTITION p5 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-06 00:00:00') ),
    PARTITION p6 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-07 00:00:00') ),
    PARTITION p7 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-08 00:00:00') ),
    PARTITION p8 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-09 00:00:00') ),
    PARTITION p9 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-10 00:00:00') ),
    PARTITION p10 VALUES LESS THAN (UNIX_TIMESTAMP('2017-12-11 00:00:00') )
);

插入数据并查看上述查询的执行计划

mysql> insert into my_range_timestamp select * from test;
Query OK, 1000000 rows affected (13.25 sec)
Records: 1000000  Duplicates: 0  Warnings: 0

mysql> explain partitions select * from my_range_timestamp where hiredate >= '20171207124503' and hiredate<='20171210111230';
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table             | partitions   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | my_range_timestamp | p7,p8,p9,p10 | ALL  | NULL          | NULL | NULL    | NULL | 400448 | Using where |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
1 row in set (0.00 sec)

同样也能实现分区裁剪。

在5.7版本之前,对于DATA和DATETIME类型的列,如果要实现分区裁剪,只能使用YEAR() 和TO_DAYS()函数,在5.7版本中,又新增了TO_SECONDS()函数。

LIST 分区

LIST分区

LIST分区和RANGE分区类似,区别在于LIST是枚举值列表的集合,RANGE是连续的区间值的集合。二者在语法方面非常的相似。同样建议LIST分区列是非null列,否则插入null值如果枚举列表里面不存在null值会插入失败,这点和其它的分区不一样,RANGE分区会将其作为最小分区值存储,HASH\KEY分为会将其转换成0存储,主要LIST分区只支持整形,非整形字段需要通过函数转换成整形.

create table t_list( 
  a int(11), 
  b int(11) 
  )(partition by list (b) 
  partition p0 values in (1,3,5,7,9), 
  partition p1 values in (2,4,6,8,0) 
  );

Hash 分区

我们在实际工作中经常遇到像会员表的这种表。并没有明显可以分区的特征字段。但表数据有非常庞大。为了把这类的数据进行分区打散mysql 提供了hash分区。基于给定的分区个数,将数据分配到不同的分区,HASH分区只能针对整数进行HASH,对于非整形的字段只能通过表达式将其转换成整数。表达式可以是mysql中任意有效的函数或者表达式,对于非整形的HASH往表插入数据的过程中会多一步表达式的计算操作,所以不建议使用复杂的表达式这样会影响性能。

Hash分区表的基本语句如下:

CREATE TABLE my_member (
    id INT NOT NULL,
    fname VARCHAR(30),
    lname VARCHAR(30),
    created DATE NOT NULL DEFAULT '1970-01-01',
    separated DATE NOT NULL DEFAULT '9999-12-31',
    job_code INT,
    store_id INT
)
PARTITION BY HASH(id)
PARTITIONS 4;

注意:

  1. HASH分区可以不用指定PARTITIONS子句,如上文中的PARTITIONS 4,则默认分区数为1。
  2. 不允许只写PARTITIONS,而不指定分区数。
  3. 同RANGE分区和LIST分区一样,PARTITION BY HASH (expr)子句中的expr返回的必须是整数值。
  4. HASH分区的底层实现其实是基于MOD函数。譬如,对于下表

CREATE TABLE t1 (col1 INT, col2 CHAR(5), col3 DATE)
PARTITION BY HASH( YEAR(col3) )
PARTITIONS 4;
如果你要插入一个col3为“2017-09-15”的记录,则分区的选择是根据以下值决定的:

MOD(YEAR(‘2017-09-01’),4)
= MOD(2017,4)
= 1

LINEAR HASH分区

LINEAR HASH分区是HASH分区的一种特殊类型,与HASH分区是基于MOD函数不同的是,它基于的是另外一种算法。

格式如下:

CREATE TABLE my_members (
    id INT NOT NULL,
    fname VARCHAR(30),
    lname VARCHAR(30),
    hired DATE NOT NULL DEFAULT '1970-01-01',
    separated DATE NOT NULL DEFAULT '9999-12-31',
    job_code INT,
    store_id INT
)
PARTITION BY LINEAR HASH( id )
PARTITIONS 4;

说明:
它的优点是在数据量大的场景,譬如TB级,增加、删除、合并和拆分分区会更快,缺点是,相对于HASH分区,它数据分布不均匀的概率更大。

KEY分区

KEY分区其实跟HASH分区差不多,不同点如下:

  1. KEY分区允许多列,而HASH分区只允许一列。
  2. 如果在有主键或者唯一键的情况下,key中分区列可不指定,默认为主键或者唯一键,如果没有,则必须显性指定列。
  3. KEY分区对象必须为列,而不能是基于列的表达式。
  4. KEY分区和HASH分区的算法不一样,PARTITION BY HASH (expr),MOD取值的对象是expr返回的值,而PARTITION BY KEY (column_list),基于的是列的MD5值。

格式如下:

CREATE TABLE k1 (
    id INT NOT NULL PRIMARY KEY,    
    name VARCHAR(20)
)
PARTITION BY KEY()
PARTITIONS 2;

在没有主键或者唯一键的情况下,格式如下:

CREATE TABLE tm1 (
    s1 CHAR(32)
)
PARTITION BY KEY(s1)
PARTITIONS 10;

总结:

  1. MySQL分区中如果存在主键或唯一键,则分区列必须包含在其中。
  2. 对于原生的RANGE分区,LIST分区,HASH分区,分区对象返回的只能是整数值。
  3. 分区字段不能为NULL,要不然怎么确定分区范围呢,所以尽量NOT NULL

PgSQL · 应用案例 · 流式计算与异步消息在阿里实时订单监测中的应用

$
0
0

背景

在很多业务系统中,为了定位问题、运营需要、分析需要或者其他需求,会在业务中设置埋点,记录用户的行为在业务系统中产生的日志,也叫FEED日志。

比如订单系统、在业务系统中环环相扣,从购物车、下单、付款、发货,收货(还有纠纷、退款等等),一笔订单通常会产生若干相关联的记录。

每个环节产生的属性可能是不一样的,有可能有新的属性产生,也有可能变更已有的属性值。

为了便于分析,通常有必要将订单在整个过程中产生的若干记录(若干属性),合并成一条记录(订单大宽表)。

通常业务系统会将实时产生的订单FEED数据写入消息队列,消息队列使得数据变成了流动的数据:

《从人类河流文明 洞察 数据流动的重要性》

方案一、RDS PG + OSS + HDB PG 分钟清洗和主动检测

数据通过消息队列消费后,实时写入RDS PG,在RDS PG进行订单FEED的合并,写入OSS外部表。(支持压缩格式,换算成裸数据的写入OSS的速度约100MB/s/会话)

HDB PG从OSS外部表读取(支持压缩格式,换算成裸数据的读取OSS的速度约100MB/s/数据节点),并将订单FEED数据合并到全量订单表。

pic

《打造云端流计算、在线业务、数据分析的业务数据闭环 - 阿里云RDS、HybridDB for PostgreSQL最佳实践》

数据进入HDB PG后,通过规则SQL,从全量订单表中,挖掘异常数据(或者分析)。

通过这种方案,实现了海量订单FEED数据的分钟级准实时分析。

这个方案已支撑了双十一业务,超高吞吐、低延迟,丝般柔滑。

方案二、毫秒级FEED监测及实时反馈方案

技术永远是为业务服务的,分钟级延迟虽然说已经很高了,但是在一些极端情况下,可能需要更低的延迟。

实际上RDS PostgreSQL还有更强的杀手锏,可以实现毫秒级的异常FEED数据发现和反馈。

流式处理+异步消息,方法如下:

1、通过触发机制结合异步消息通道实现。

pic

2、通过pipeline,流式SQL结合异步消息通道实现。

pic

应用程序监听消息通道(listen channel),数据库则将异常数据写入到消息通道(notify channel, message)。实现异常数据的主动异步推送。

毫秒级FEED监测与反馈架构设计

之前不做毫秒级的FEED监测,还有一个原因是HBASE的合并延迟较高,导致流计算在补齐字段时异常。使用RDS PG来实现异常监测,完全杜绝了补齐的问题,因为在RDS PG就包含了全字段,不存在补齐的需求。

pic

RDS PG设计

1、分实例,提高系统级吞吐。(例如单实例处理能力是15万行/s,那么100个实例,可以支撑1500万行/s的实时处理。)

例如:

DB0, DB1, DB2, DB3, ..., DB255    

映射关系:

db0, host?, port?    
    
db1, host?, port?    
    
...    

2、实例内使用分表,提高单实例并行处理吞吐。当规则众多时,分表可以提高单实例的规则处理吞吐。

例如

tbl0, tbl1, tbl2, ..., tbl127    
    
tbl128, tbl129, tbl130, ..., tbl255    

映射关系:

tbl0, db?    
    
tbl1, db?    
    
...    

HDB PG设计

HDB PG依旧保留,用于PB级数据量的海量数据实时分析。

数据通路依旧采用OSS,批量导入的方式。

DEMO

1、创建订单feed全宽表(当然,我们也可以使用jsonb字段来存储所有属性。因为PostgreSQL支持JSONB类型哦。PostgreSQL支持的多值类型还有hstore, xml等。)

create table feed(id int8 primary key, c1 int, c2 int, c3 int, c4 int, c5 int, c6 int, c7 int, c8 int, c9 int, c10 int, c11 int, c12 int);    

2、订单FEED数据的写入,例如A业务系统,写入订单的c1,c2字段。B业务系统,写入订单的c3,c4字段。……

使用on conflict do something语法,进行订单属性的合并。

insert into feed (id, c1, c2) values (2,2,30001) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    
    
insert into feed (id, c3, c4) values (2,99,290001) on conflict (id) do update set c3=excluded.c3, c4=excluded.c4 ;    

3、建立订单FEED的实时监测规则,当满足条件时,向PostgreSQL的异步消息中发送消息。监听该通道的APP,循环从异步消息获取数据,即可满足消息的实时消费。

规则可以保留在TABLE中,也可以写在触发器代码中,也可以写在UDF代码中。

3.1、如果数据是批量写入的,可以使用语句级触发器,降低触发器函数被调用的次数,提高写入吞吐。

create or replace function tg1() returns trigger as $$    
declare    
begin     
  -- 规则定义,实际使用时,可以联合规则定义表    
  -- c2大于1000时,发送异步消息    
  perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(inserted)) from inserted where c2>1000;      
    
  -- 多个规则,写单个notify的方法。    
  --   perform pg_notify(    
  --                    'channel_1',      
  --                   case     
  --                    when c2>1000 then 'Resone:c2 overflow::'||row_to_json(inserted)     
  --                    when c1>200 then 'Resone:c1 overflow::'||row_to_json(inserted)     
  --                   end    
  --                  )     
  --   from inserted     
  --   where     
  --     c2 > 1000     
  --     or c1 > 200;      
    
  -- 多个规则,可以写多个notify,或者合并成一个NOTIFY。    
    
  return null;    
end;    
$$ language plpgsql strict;    

3.2、如果数据是单条写入的,可以使用行级触发器。(本例后面的压测使用这个)

create or replace function tg2() returns trigger as $$    
declare    
begin     
  -- 规则定义,实际使用时,可以联合规则定义表    
    
  -- c2大于9999时,发送异步消息    
  perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2>9999;      
    
  -- 多个规则,调用单个notify,写一个CHANNEL的方法。    
  --   perform pg_notify(    
  --                    'channel_1',      
  --                   case     
  --                    when c2>1000 then 'Resone:c2 overflow::'||row_to_json(NEW)     
  --                    when c1>200 then 'Resone:c1 overflow::'||row_to_json(NEW)     
  --                   end    
  --                  )     
  --   where     
  --     NEW.c2 > 10000     
  --     or NEW.c1 > 200;      
    
  -- 多个规则,调用单个notify,写多个CHANNEL的方法。    
  --   perform pg_notify(    
  --                   case     
  --                    when c2>1000 then 'channel_1'     
  --                    when c1>200 then 'channel_2'     
  --                   end,    
  --                   case     
  --                    when c2>1000 then 'Resone:c2 overflow::'||row_to_json(NEW)     
  --                    when c1>200 then 'Resone:c1 overflow::'||row_to_json(NEW)     
  --                   end    
  --                  )     
  --   where     
  --     NEW.c2 > 1000     
  --     or NEW.c1 > 200;      
    
  -- 多个规则,可以写多个notify,或者合并成一个NOTIFY。    
  -- 例如    
  -- perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2 > 1000;    
  -- perform pg_notify('channel_2', 'Resone:c1 overflow::'||row_to_json(NEW)) where NEW.c1 > 200;    
    
  -- 也可以把规则定义在TABLE里面,实现动态的规则    
  -- 规则不要过于冗长,否则会降低写入的吞吐,因为是串行处理规则。    
  -- udf的输入为feed类型以及rule_table类型,输出为boolean。判断逻辑定义在UDF中。    
  -- perfrom pg_notify(channel_column, resone_column||'::'||row_to_json(NEW)) from rule_table where udf(NEW::feed, rule_table);    
    
  return null;    
end;    
$$ language plpgsql strict;    

3.3、如上代码中所述,规则可以定义在很多地方。

4、创建触发器。

4.1、语句级触发器(批量写入,建议采用)

create trigger tg1 after insert on feed REFERENCING NEW TABLE AS inserted for each statement execute procedure tg1();    
create trigger tg2 after update on feed REFERENCING NEW TABLE AS inserted for each statement execute procedure tg1();    

4.2、行级触发器(单步写入建议采用),(本例后面的压测使用这个)

create trigger tg1 after insert on feed for each row execute procedure tg2();    
create trigger tg2 after update on feed for each row execute procedure tg2();    

5、协商好通道名称。

6、应用端监听消息通道。

listen channel_1;    
    
接收消息:    
    
loop    
  sleep ?;    
  get 消息;    
end loop    

7、写入订单数据,每行数据都会实时过触发器,在触发器中写好了逻辑,当满足一些规则时,向协商好的消息通道发送消息。

postgres=# insert into feed (id, c1, c2) values (2,2,30001) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    
INSERT 0 1    

8、接收到的消息样本如下:

Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2,"c1":2,"c2":30001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    

9、批量插入

postgres=# insert into feed (id, c1, c2)  select id,random()*100, random()*1001 from generate_series(1,10000) t(id) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    
INSERT 0 10000    
Time: 59.528 ms    

一次接收到的样本如下:

Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":362,"c1":92,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4061,"c1":90,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4396,"c1":89,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":5485,"c1":72,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":6027,"c1":56,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":6052,"c1":91,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7893,"c1":84,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8158,"c1":73,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    

10、更新数据

postgres=# update feed set c1=1;    
UPDATE 10000    
Time: 33.444 ms    

接收到的异步消息样本如下:

Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":1928,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2492,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2940,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2981,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4271,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4539,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7089,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7619,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8001,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8511,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8774,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":9394,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    

压测1 - 单步实时写入

1、假设每1万条记录中,有一条异常记录需要推送,这样的频率算是比较现实的。

vi test.sql    
    
\set id random(1,10000000)    
\set c1 random(1,1001)    
\set c2 random(1,10000)    
insert into feed (id, c1, c2) values (:id, :c1, :c2) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    

2、压测结果,16.7万 行/s 处理吞吐。

transaction type: ./test.sql    
scaling factor: 1    
query mode: prepared    
number of clients: 56    
number of threads: 56    
duration: 120 s    
number of transactions actually processed: 20060111    
latency average = 0.335 ms    
latency stddev = 0.173 ms    
tps = 167148.009836 (including connections establishing)    
tps = 167190.475312 (excluding connections establishing)    
script statistics:    
 - statement latencies in milliseconds:    
         0.002  \set id random(1,10000000)    
         0.001  \set c1 random(1,1001)    
         0.000  \set c2 random(1,10000)    
         0.332  insert into feed (id, c1, c2) values (:id, :c1, :c2) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    

3、监听到的异步消息采样

postgres=# listen channel_1;    
LISTEN    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":3027121,"c1":393,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 738.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":5623104,"c1":177,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 758.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":3850742,"c1":365,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 695.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":5244809,"c1":55,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 716.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4062585,"c1":380,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 722.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8536437,"c1":560,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 695.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7327211,"c1":365,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 728.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":431739,"c1":824,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 731.    

单实例分表的schemaless设计

请参考如下用法或案例,目的是自动建表,自动分片。

《PostgreSQL 在铁老大订单系统中的schemaless设计和性能压测》

《PostgreSQL 按需切片的实现(TimescaleDB插件自动切片功能的plpgsql schemaless实现)》

《PostgreSQL schemaless 的实现》

《PostgreSQL 时序最佳实践 - 证券交易系统数据库设计 - 阿里云RDS PostgreSQL最佳实践》

压测2 - 单实例分表实时写入

1、创建订单feed全宽表模板表

create table feed(id int8 primary key, c1 int, c2 int, c3 int, c4 int, c5 int, c6 int, c7 int, c8 int, c9 int, c10 int, c11 int, c12 int);    

2、定义规则

create or replace function tg() returns trigger as $$    
declare    
begin     
  -- c2大于9999时,发送异步消息,  
  perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2>9999;     
  
  -- 写入各个通道的例子,通过trigger parameter传入通道后缀(也可以写入单一通道,具体看设计需求)   
  -- perform pg_notify('channel_'||TG_ARGV[0], 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2>9999;      
    
  return null;    
end;    
$$ language plpgsql strict;    

3、定义分表

do language plpgsql $$  
declare  
begin  
  for i in 1..512 loop  
    execute 'create table feed'||i||'(like feed including all) inherits (feed)';  
    -- 创建触发器(采用行级触发) , 本例采用静态规则(when (...)),实际使用请使用动态规则,处理所有行   
    execute 'create trigger tg1 after insert on feed'||i||' for each row WHEN (NEW.c2>9999) execute procedure tg()';    
    execute 'create trigger tg2 after update on feed'||i||' for each row WHEN (NEW.c2>9999) execute procedure tg()';     
  end loop;  
end;  
$$;  

4、定义动态写入分表UDF(这个逻辑可以在应用层实现,本例只是演示单实例分表后的写吞吐能达到多少)

单条提交。

create or replace function ins(int,int8,int,int) returns void as $$  
declare  
begin  
  execute format('insert into feed%s (id,c1,c2) values (%s,%s,%s) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2', $1, $2, $3, $4) ;    
end;  
$$ language plpgsql strict;   

批量提交。

create or replace function ins(int, int8) returns void as $$  
declare  
begin  
  execute format('insert into feed%s (id,c1,c2) %s on conflict (id) do update set c1=excluded.c1, c2=excluded.c2', $1, 'select id, random()*100, random()*10000 from generate_series('||$1||','||$1+1000||') t (id)') ;    
end;  
$$ language plpgsql strict;   

5、假设每1万条记录中,有一条异常记录需要推送,这样的频率算是比较现实的。

单条提交。

vi test.sql    
    
\set suffix random(1,512)  
\set id random(1,10000000)    
\set c1 random(1,1001)    
\set c2 random(1,10000)    
select ins(:suffix, :id, :c1, :c2);  

批量提交。

vi test.sql    
    
\set suffix random(1,512)  
\set id random(1,10000000)    
select ins(:suffix, :id);  

6、压测结果

单条提交, 15万 行/s处理吞吐。

相比单表单步写入略低,原因是采用了动态SQL(在UDF拼接分表),这部分逻辑放到APP端,性能有20%的提升。

transaction type: ./test.sql  
scaling factor: 1  
query mode: prepared  
number of clients: 112  
number of threads: 112  
duration: 120 s  
number of transactions actually processed: 18047334  
latency average = 0.744 ms  
latency stddev = 0.450 ms  
tps = 150264.463046 (including connections establishing)  
tps = 150347.026261 (excluding connections establishing)  
script statistics:  
 - statement latencies in milliseconds:  
         0.002  \set suffix random(1,512)  
         0.001  \set id random(1,10000000)    
         0.001  \set c1 random(1,1001)    
         0.000  \set c2 random(1,10000)    
         0.742  select ins(:suffix, :id, :c1, :c2);  

批量提交(1000行/批),117万 行/s处理吞吐。

批量提交有了质的提升。

transaction type: ./test.sql  
scaling factor: 1  
query mode: prepared  
number of clients: 56  
number of threads: 56  
duration: 120 s  
number of transactions actually processed: 140508  
latency average = 47.820 ms  
latency stddev = 17.175 ms  
tps = 1169.851558 (including connections establishing)  
tps = 1170.150203 (excluding connections establishing)  
script statistics:  
 - statement latencies in milliseconds:  
         0.002  \set suffix random(1,512)  
         0.000  \set id random(1,10000000)    
        47.821  select ins(:suffix, :id);  

jdbc 异步消息使用例子

https://jdbc.postgresql.org/documentation/81/listennotify.html

import java.sql.*;    
    
public class NotificationTest {    
    
        public static void main(String args[]) throws Exception {    
                Class.forName("org.postgresql.Driver");    
                String url = "jdbc:postgresql://localhost:5432/test";    
    
                // Create two distinct connections, one for the notifier    
                // and another for the listener to show the communication    
                // works across connections although this example would    
                // work fine with just one connection.    
                Connection lConn = DriverManager.getConnection(url,"test","");    
                Connection nConn = DriverManager.getConnection(url,"test","");    
    
                // Create two threads, one to issue notifications and    
                // the other to receive them.    
                Listener listener = new Listener(lConn);    
                Notifier notifier = new Notifier(nConn);    
                listener.start();    
                notifier.start();    
        }    
    
}    
    
class Listener extends Thread {    
    
        private Connection conn;    
        private org.postgresql.PGConnection pgconn;    
    
        Listener(Connection conn) throws SQLException {    
                this.conn = conn;    
                this.pgconn = (org.postgresql.PGConnection)conn;    
                Statement stmt = conn.createStatement();    
                stmt.execute("LISTEN mymessage");    
                stmt.close();    
        }    
    
        public void run() {    
                while (true) {    
                        try {    
                                // issue a dummy query to contact the backend    
                                // and receive any pending notifications.    
                                Statement stmt = conn.createStatement();    
                                ResultSet rs = stmt.executeQuery("SELECT 1");    
                                rs.close();    
                                stmt.close();    
    
                                org.postgresql.PGNotification notifications[] = pgconn.getNotifications();    
                                if (notifications != null) {    
                                        for (int i=0; i<notifications.length; i++) {    
                                                System.out.println("Got notification: " + notifications[i].getName());    
                                        }    
                                }    
    
                                // wait a while before checking again for new    
                                // notifications    
                                Thread.sleep(500);    
                        } catch (SQLException sqle) {    
                                sqle.printStackTrace();    
                        } catch (InterruptedException ie) {    
                                ie.printStackTrace();    
                        }    
                }    
        }    
    
}    
    
class Notifier extends Thread {    
    
        private Connection conn;    
    
        public Notifier(Connection conn) {    
                this.conn = conn;    
        }    
    
        public void run() {    
                while (true) {    
                        try {    
                                Statement stmt = conn.createStatement();    
                                stmt.execute("NOTIFY mymessage");    
                                stmt.close();    
                                Thread.sleep(2000);    
                        } catch (SQLException sqle) {    
                                sqle.printStackTrace();    
                        } catch (InterruptedException ie) {    
                                ie.printStackTrace();    
                        }    
                }    
        }    
    
}    

libpq 异步消息的使用方法

https://www.postgresql.org/docs/10/static/libpq-notify.html

触发器的用法

https://www.postgresql.org/docs/10/static/sql-createtrigger.html

《PostgreSQL 触发器 用法详解 1》

《PostgreSQL 触发器 用法详解 2》

注意事项

1、异步消息快速接收,否则会占用实例 $PGDATA/pg_notify的目录空间。

2、异步消息上限,没有上限,和存储有个。

buffer大小:

/*    
 * The number of SLRU page buffers we use for the notification queue.    
 */    
#define NUM_ASYNC_BUFFERS       8    

3、异步消息可靠性,每个异步消息通道,PG都会跟踪监听这个通道的会话已接收到的消息的位置偏移。

新发起的监听,只从监听时该通道的最后偏移开始发送,该偏移之前的消息不会被发送。

消息接收后,如果没有任何监听需要,则会被清除。

监听消息通道的会话,需要持久化,也就是说会话断开的话,(未接收的消息,以及到会话重新监听这段时间,新产生的消息,都收不到)

4、如果需要强可靠性(替换掉异步消息,使用持久化的模式)

方法:触发器内pg_notify改成insert into feedback_table ....;

持久化消息的消费方法,改成如下(阅后即焚模式):

with t1 as (select ctid from feedback_table order by crt_time limit 100)     
  delete from feedback_table where     
    ctid = any (array(select ctid from t1))    
    returning *;    

持久化消息,一样能满足10万行以上的消费能力(通常异常消息不会那么多,所以这里可以考虑使用单个异常表,多个订单表)。

只不过会消耗更多的RDS PG的IOPS,(产生写 WAL,VACUUM WAL。)

其他

1、已推送的异常,当数据更新后,可能会被再次触发,通过在逻辑中对比OLD value和NEW value可以来规避这个问题。本文未涉及。实际使用是可以改写触发器代码。

实时计算处理吞吐

1、RDS PostgreSQL 单实例处理吞吐达到了 117万 行/s。性价比超级棒。

2、100个RDS PostgreSQL,可以轻松达到 1亿 行/s (60亿/分钟) 的处理吞吐。宇宙无敌了。

参考

《在PostgreSQL中实现update | delete limit - CTID扫描实践 (高效阅后即焚)》

《(流式、lambda、触发器)实时处理大比拼 - 物联网(IoT)\金融,时序处理最佳实践》

《PostgreSQL 10.0 preview 功能增强 - 触发器函数内置中间表》

https://www.postgresql.org/docs/10/static/sql-createtrigger.html

https://jdbc.postgresql.org/documentation/81/listennotify.html

https://www.postgresql.org/docs/10/static/libpq-notify.html

《(流式、lambda、触发器)实时处理大比拼 - 物联网(IoT)\金融,时序处理最佳实践》

MySQL · 数据恢复 · undrop-for-innodb

$
0
0

简介

undrop-for-innodb 是针对 innodb 的一套数据恢复工具,可以从文件级别恢复诸如:DROP/TRUNCATE table, 删除表中某些记录,innodb 文件被删除,文件系统损坏,磁盘 corruption 等几种情况。本文简单介绍下使用方法和原理浅析。

准备

git clone https://github.com/twindb/undrop-for-innodb.git 
make

需要联合 MySQL 的安装路径编译工具 sys_parser,

gcc `$basedir/bin/mysql_config --cflags` `$basedir/bin/mysql_config --libs` -o sys_parser sys_parser.c

需要的工具都已经完备: 420d94d6-79de-49b3-ad6c-c2648307d1dc.png

  • 重要的工具: c_parser && stream_parser && sys_parser
  • 其中 test.sh && recover_dictionary.sh && fetch_data.sh是测试的脚本,可以看下里面的逻辑理解工具的用法。
  • 三个目录
  • dictionary 里面是模拟 innodb 系统表结构写的 CREATE TABLE 语句,innodb 的系统表对用户不可见,可以在 informatioin_schema 表中找到一些值,但实际上系统表是保存在 ibdata 固定的页上的。
  • sakila 是一些 SQL 语句,用来测试用。
  • include 是从 innodb 拿出来的一些用到的头文件和源文件。

DROP TABLE

表结构恢复

一般情况下表结构是存储在 frm 文件中,drop table 会删除 frm 文件,还好我们可以从 innodb 系统表里读取一些信息恢复表结构。innodb 系统表有

SYS_COLUMNS | SYS_FIELDS | SYS_INDEXES | SYS_TABLES 

关于系统表结构的具体介绍可以参考 系统表 , 这几个表对于恢复非常重要,下面以一个恢复表结构的例子来说明。

使用目录 sakila/actor.sql 中的例子:

CREATE TABLE `actor` (
  `actor_id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8;

insert into actor(first_name, last_name) values('zhang', 'jian');
insert into actor(first_name, last_name) values('zhan', 'jian');
insert into actor(first_name, last_name) values('zha', 'jian');
insert into actor(first_name, last_name) values('zh', 'jian');
insert into actor(first_name, last_name) values('z', 'jian');

mysql> checksum table actor;
+-----------+------------+
| Table     | Checksum   |
+-----------+------------+
| per.actor | 2184463059 |
+-----------+------------+
1 row in set (0.00 sec)
DROP TABLE actor

需要从系统表中恢复,而系统表是保存在 $datadir/ibdata1 文件中的,使用工具 stream_parser解析文件内容。

$./stream_parser -f /home/zj118228/rds_5616/data/ibdata1

执行完毕后会在当前目录下生成文件夹 pages-ibdata1 , 目录下按照每个页为一个文件,分为索引页和数据较大的 BLOB 页,我们访问系统表的话,是存在索引页中的。使用另外一个重要的工具 c_parser来解析页的内容。

$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000001.page -t dictionary/SYS_TABLES.sql  | grep 'sakila/actor'
000000005927 24000001C809C6 SYS_TABLES "sakila/actor" 52 4 1 0 80 "" 38

参数解析:

  • 4 表示文件格式是 REDUNDANT,系统表的格式默认值。另外可以取值 5 表示 COMPACT 格式,6 表示 MySQL 5.6 格式。
  • D 表示只恢复被删除的记录。
  • f 后面跟着文件。
  • t 后面跟着 CREATE TABLE 语句,需要根据表的格式来解析文件。

得到的结果 ‘SYS_TABLES’ 字段后面的就是系统表 SYS_TABLE 中对应存的记录。 同样的恢复其它三个系统表:

/* --- SYS_INDEXES 'grep 52' 是对应 SYS_TABLE 的 TALE ID --- */ 
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000003.page -t dictionary/SYS_INDEXES.sql  | grep '52'
000000005927 24000001C807FF SYS_INDEXES 52 57 "PRIMARY" 1 3 38 4294967295
000000005927 24000001C80871 SYS_INDEXES 52 58 "idx\_actor\_last\_name" 1 0 38 4294967295

/* --- SYS_COLUMNS --- */
./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000002.page -t dictionary/SYS_COLUMNS.sql | grep 52
000000005927 24000001C808F2 SYS_COLUMNS 52 0 "actor\_id" 6 1794 2 0
000000005927 24000001C80927 SYS_COLUMNS 52 1 "first\_name" 12 2162959 135 0
000000005927 24000001C8095C SYS_COLUMNS 52 2 "last\_name" 12 2162959 135 0
000000005927 24000001C80991 SYS_COLUMNS 52 3 "last\_update" 3 525575 4 0

/* --- SYS_FIELD  'grep 57\|58' 对应 SYS_INDEXES 的 ID 列 --- */
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000004.page -t dictionary/SYS_FIELDS.sql | grep '57\|58'
000000005927 24000001C807CA SYS_FIELDS 57 0 "actor\_id"
000000005927 24000001C8083C SYS_FIELDS 58 0 "last\_name"

我们恢复表结构的数据都在这四张系统表中了,SYS_COLUMNS 后面几列的表示 MySQL 内部对于数据类型的编号。

接下来是恢复阶段

  1. 使用目录 dictionary 下的四个文件创建四张表(这里数据库名为 recover )。
  2. 把上面恢复出来的数据分别导入到对应的表中(注意相同的行去重)。
  3. 使用工具 sys_parser 恢复 CREATE TABLE 语句。
$./sys_parser -h 127.0.0.1 -u root -P 56160 -d recover sakila/actor
CREATE TABLE `actor`(
 `actor_id` SMALLINT UNSIGNED NOT NULL,
 `first_name` VARCHAR(45) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NOT NULL,
 `last_name` VARCHAR(45) CHARACTER SET 'utf8' COLLATE 'utf8_general_ci' NOT NULL,
 `last_update` TIMESTAMP NOT NULL,
 PRIMARY KEY (`actor_id`)
) ENGINE=InnoDB;

对比发现,恢复出来的 CREATE TABLE 语句相比原来创建的语句信息量有点缺少,因为 innodb 系统表里面存的数据相比 frm 文件是不足的,比如 AUTO_INCREMENT, DECIMAL 类型的精度信息都会缺失,也不会恢复二级索引,外建等。可以看成是表存储结构的恢复。如果有 frm 文件就可以完完整整的恢复了,这篇文章介绍了恢复方法:Get Create Table From frm

表数据恢复

innodb_file_per_table off

这种情况表中的数据是保存在 ibdata 文件中的,虽然表的数据在数据库中被删除了,但是如果没有被重写,数据还在保存在文件中的,执行下列步骤来恢复:

  1. 使用 stream_parser 分析 ibdata 文件,分别得到每个页的文件。
 $./stream_parser -f /home/zj118228/rds_5616/data/ibdata1
  1. 如表结构分析小节中所示,使用 c_parser分析系统表 SYS_TABLES 和 SYS_INDEXES ,根据表名得到 TABLE ID, 根据 TABLE ID 得到 INDEX ID。(INDEX ID 就是上述例子的第 5 列,值为 57 和 58)
  2. 根据得到的 INDEX ID,到目录 pages-ibdata1 下去找对应的页号,这就是对应的索引表数据所在的数据页。
  3. 使用 c_parser 读取第 3 步得到的页文件,得到数据。
$./c_parser -6f pages-ibdata1/FIL_PAGE_INDEX/0000000000000065.page -t actor.sql
-- Page id: 579, Format: COMPACT, Records list: Valid, Expected records: (5 5)
000000005D95 E5000001960110 actor 201 "zhang""jian""2017-11-04 12:30:07"
000000005D96 E6000001970110 actor 202 "zhan""jian""2017-11-04 12:30:07"
000000005D98 E80000019A0110 actor 203 "zha""jian""2017-11-04 12:30:07"
000000005D99 E90000019B0110 actor 204 "zh""jian""2017-11-04 12:30:07"
000000005DA9 F1000002480110 actor 205 "z""jian""2017-11-04 12:30:08"

数据看起来没什么问题,表结构和数据都有了,导进去即可,看一下 checksum 也相同。

mysql> checksum table actor;
+-----------+------------+
| Table     | Checksum   |
+-----------+------------+
| per.actor | 2184463059 |
+-----------+------------+
1 row in set (0.00 sec)

innodb_file_per_table on

这种情况下表是保存在各自的 ibd 文件中的,当 drop table 之后 ,ibd 文件会被删除,此时最好能够设置磁盘整体只读,避免有其它进程重写文件块。整体的恢复步骤和上一个小节(innodb_file_per_table off) 相同,只是无法从 pages-ibdata1 目录下面找到对应的 page 号。 假设已经完成了前两步,拿到了 INDEX ID。

stream_parser这个工具不但可以读文件,还可以读磁盘,会根据 innodb 数据格式把数据页读出来。为了恢复 68 号数据页,我们执行下面几个步骤:

  1. 找到被删除的 ibd 文件的挂载磁盘 /dev/sda5:

     $df 
     Filesystem           1K-blocks      Used Available Use% Mounted on
     /dev/sda2             52327276  47003636   2702200  95% /
     tmpfs                 99225896   9741300  89484596  10% /dev/shm
     /dev/sda1               258576     55291    190229  23% /boot
     /dev/sda5            1350345636 1142208356 208137280  85% /home
     /dev/sdb1            3278622264 2277365092 1001257172  70% /disk1
    
  2. 根据 INDEX ID , 磁盘大小执行 stream_parser,-t 表示磁盘的大小。

     	$./stream_parser -f /dev/sda5 -s 1G -t 1142G
    
  3. 在目录 pages—sda5 下找到 68 号页,像上一个小节第 4 步一样恢复数据即可。
  4. <划重点>测试了三次,有两次是恢复不出来的,因为文件很可能被其它进程重写,这取决于文件系统调度还有整体服务器的负载。 划重点>
  5. 如果挂载的磁盘上还有其它 mysqld 的数据目录,那么很可能一个 page 文件会很大,监测到其它 ibd 文件的数据,同一个页号的综合在一起,这样辨别出我们需要的数据就比较麻烦。

文件页脏写

MySQL 每次从磁盘读取数据的时候都会进行 checksum 校验,如果校验失败,整个进程就会重启或者退出,校验失败很可能是文件页被脏写了。使用恢复工具直接读取文件很可能可以把未被脏写的行或者页读取出来,损失降到最低。

模拟脏写

同样使用目录 sakila/actor.sql 中的例子,innodb_per_file_table = on:

CREATE TABLE `actor` (
  `actor_id` smallint(5) unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8;

insert into actor(first_name, last_name) values('zhang', 'jian');
insert into actor(first_name, last_name) values('zhan', 'jian');
insert into actor(first_name, last_name) values('zha', 'jian');
insert into actor(first_name, last_name) values('zh', 'jian');
insert into actor(first_name, last_name) values('z', 'jian');

模拟脏写,打开 actor.ibd 文件, 使用 ‘#’ 覆盖其中一行数据, 00fdb870-b7df-4122-b7d9-1622bc354737.png

从系统表空间确定 INDEX ID (参考 表结构恢复 小节)

$./stream_parser -f /home/zj118228/rds_5616/data/ibdata1
$./stream_parser -f ~/rds_5616/data/per/actor.ibd
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000001.page -t dictionary/SYS_TABLES.sql
$./c_parser -4Df pages-ibdata1/FIL_PAGE_INDEX/0000000000000003.page -t dictionary/SYS_INDEXES.sql

INDEX ID 为 76,读取数据:

$./c_parser -6f pages-actor.ibd/FIL_PAGE_INDEX/0000000000000076.page -t sakila/actor.sql

d27b1b03-ac82-4d60-9717-1c58d9587e64.png看到有一行数据被 # 号覆盖,然后丢失了一行。

脏写之后数据库是起不来的,因为 ibd 文件已经损坏了,但此时我们已经拿到了恢复之后的数据,需要把恢复之后的数据导入到数据库里。导入之前删除 actor.ibd 文件,然后启动数据库后执行 drop table actor, 然后再重新创建表,导入数据即可。如果不小心把 frm 文件也删掉了,是没法 drop table 的,可以在其它数据库里建一个同名,结构相同的表生成 frm 文件,然后拷贝到被删除的目录下,然后再执行 drop table。参考:Troubleshooting

原理浅析

c_parser

恢复工具 c_parser其实是按照 innodb 存储数据的格式来分析哪些是我们需要的数据本身,所以页上的数据可以分为两类:1. 用户数据 2. 元数据。而元数据的功能其实并不相同,有些损坏无伤大雅,有些损坏却可能导致整个页无法恢复。这里有几篇介绍Innodb 行记录格式1 and Innodb 行记录格式2,上一个小节中行记录格式是 Compact,来分析一下为什么会丢了一行数据。

这是完好的数据页,上面是脏写是把第 12 行数据全部覆盖了,根据 Compact 类型的格式,12 行末尾的 04 03 表示下一行变长数据类型(‘zha’ ‘jian’)的长度倒序,被覆盖之后当然无法解析,于是就丢了一行。那么为什么没有影响后续的行数据呢?第 13 行第 2 列的数据 21 表示下行数据的偏移,幸运的没有被覆盖。如果这个字节被覆盖,那么整个格式就乱了,无法解析。 4abe1824-db59-46a4-a610-a24a3cd9bfd0.png

试了其它几种情况:

  • 第六行第五列 004C 表示 page 的号,破坏之后 stream 出来的页号会变,所以从 Innodb 系统表得到的主键索引页号就不对了。
  • infimumsupremum破坏之后 stream 无法检测出页,所以根本产生不了可恢复的数据。

stream_parser

c_parser是分析页面中用户的行数据,从参数中传入 CREATE TABLE语句,根据定义的数据格式逐行解析,得到最终恢复的数据。而 stream_parser是分析 ibd/ibdata 文件(或者挂载的磁盘),得到每一个数据页的。根据数据页的元数据,如果满足下列条件,就被认为是一个合法的 Innodb Index 数据页:

  • 页面最开始前四个字节(checksum)不为 0
  • 页面 5-8 字节(页面在 tablespace 中的偏移)不为零,且小于 (ib_size / UNIV_PAGE_SIZE) 最大偏移量,ibd 文件大小除以 Innodb 页大小。
  • 在固定偏移处找到 infimumsupremum

参考 stream_parser.c中的函数 valid_innodb_page, 关于 Blob page 判定条件略有不同,详细参考 valid_blob_page,这里以 Index page 为例。

得到一个合法的页后就以 UNIV_PAGE_SIZE 为大小写入到以 index_id 命名的文件中(也就是 c_parser读入的页号判断标准)。

页数据格式

这里引用下登博画的大图: undefined

根据图中数据格式,如果页面前 8 字节被重写为 0 ,infimumsupremum被写坏,stream_parser无法检测出有效页。如果图中 Page_no 被写坏,那么我们从 Innodb 数据字典中获得的需要解析的文件页号恐怕就不对了,也不知道从那里去恢复。

所以这种恢复方式是寄托在重要页元数据和行元数据没有被脏写的前提下的,上述分析过后,重要的元数据所占比例较小,如果每个字节被脏写的概率相同,那么数据的可恢复性还是比较可观的。

最后,对于文件系统损坏或者磁盘 corruption,最重要的把数据拷贝出来,而不是去恢复文件系统或者磁盘,因为上述工具的恢复是基于数据的,参考这篇文章,第一时间使用 dd 命令制作磁盘镜像,再走上述的恢复流程即可。

MySQL · 引擎特性 · DROP TABLE之binlog解析

$
0
0

Drop Table的特殊之处

Drop Table乍一看,与其它DDL 也没什么区别,但当你深入去研究它的时候,发现还是有很多不同。最明显的地方就是DropTable后面可以紧跟多个表,并且可以是不同类型的表,这些表还不需要显式指明其类型,比如是普通表还是临时表,是支持事务的存储引擎的表还是不支持事务的存储引擎的表等。这些特殊之处对于代码实现有什么影响呢?对于普通表,无论是创建还是删除,数据库都会产生相应的binlog日志,而对于临时表来说,记录binlog日志就不是必须的。对于采用不同存储引擎的表来说,更是如此,有些存储引擎不支持事务如MyISAM,而有些存储引擎支持事务如Innodb,对于支持事务和不支持事务的存储引擎,处理方式也有些许差异。而Drop Table可以跟多种不同类型的表,就必须对这些情况分类处理。因此有必要对MySQL的DROP TABLE实现进行更深入的研究,以了解个中不同之处,防止被误解误用。

MySQL中Drop Table不支持事务

MySQL中对于DDL本身的实现与其它数据库也存在一些不同,比如无论存储引擎是什么,支持事务Innodb或是不支持事务MyISAM,MySQL的DDL都不支持事务,也不能被包含在一个长事务中,即使用begin/end或start transaction/commit包含多条语句的事务。如果在长事务中出现DDL,则在执行DDL之前,数据库会自动将DDL之前的事务提交。Drop Table可以同时删除多个表,这些表可能存在,也可能不存在。如果删除列表中的某个表不存在,数据库仍会继续删除其它存在的表,但最终会输出一条表不存在的错误消息。如要删除t1,t2,t3,t4,t5,则t1,t2,t5表存在,t3,t4表不存在,则语句Drop Table t1,t2,t3,t4,t5;会删除t1,t2,t5,然后返回错误:ERROR 1051 (42S02): Unknown table ‘test.t3,test.t4’而在其它数据库中,比如PostgreSQL,就会将事务回滚,不会删除任何一张表。

Drop Table如何记录binlog?

在MySQL中,通过binlog进行主备之间的复制,保证主备节点间的数据一致,对于Drop table又有什么不同吗?仔细研究一下,还真的有很大的不同。MySQL支持两种binlog格式,statement和row,实践中还有一种是两者混合格式mixed。不同的binlog格式对SQL语句的binlog产生也会有不同的影响,尤其对Drop table来说,因为Drop table有很多之前提到的特殊之处,如可能同时删除多个不同类型的表,甚至删除不存在的表,因此在产生binlog时必须对这些不同类型的表或者不存在的表进行特殊的处理。

不存在表的处理

对于不存在表,实际上也没有表的定义, MySQL将其统一认作普通表,并按普通表来记录binlog。如Drop table if exists t1, t2,t3; 其中t1,t3存在,t2不存在;则会产生binlog如下所示:DROP TABLE IF EXISTS t1,t2,t3;

临时表的处理

此外影响最大的就是对临时表的处理,在statement格式下,所有对临时表的操作都要记录binlog,包括创建、删除及DML语句;但在row格式下,只有Drop table才会记录binlog,而对临时表的创建及DML语句是不记录binlog的。为什么会这样?通常情况下,主机的临时表在备机上是没有用的,临时表只在当前session中有效,即使将临时表同步到备机,当用户从主机切换到备机时,原来session已经中断,与session关联的临时表也会被清除,用户会重建session到新的主机。但在一些特殊情况下,还是需要将主机的临时表同步到备机的,比如主机上执行insert into t1 select * from temp1,其中t1是普通表,而temp1是临时表。当binlog格式为statement时,这条语句会被记录到binlog,然后同步到备机,在备机上replay,若备机之前没有将主机上的临时表同步过来,那这条语句的replay就会出现问题。因此在statement格式下,对临时表的操作如创建、删除及其它DML语句都必须记录binlog,然后同步到备机执行replay。但在row格式下,因为binlog中已经记录了实际的row,那么对临时表的创建、DML语句是不是记录binlog就不是那么重要了。这里有一点比较特殊,对临时表的删除还是要记录binlog。因为用户可以随时修改binlog的格式,若之前创建临时表时是statement格式,而创建成功后,又修改为row格式,若row格式下删除表不记录binlog,那么在备机上就会产生问题,创建了临时表,但却没有删除它。因此对drop table语句,无论binlog格式采用statement或是row格式,都会记录binlog。而对于创建临时表语句,只有statement格式会记录binlog,而在row格式下,不记录binlog。为防止row格式下在备机上replay时drop不存在的临时表,会将drop临时表的binlog中添加IF EXISTS,防止删除不存在的表replay失败。

不同类型表的处理

另外,drop table在产生binlog还有一个诡异的地方,通常一条SQL语句只会产生一个binlog event,占用一个gitd_executed,但drop table有可能会产生多个binlog event,并占用多个gtid_executed。如下示例:DROP TABLE t1, tmp1, i1, no1;其中t1为普通表,tmp1为innodb引擎的临时表,i1为MyISAM引擎的临时表,no1为不存在的表。则会产生3条binlog events,并且每个binlog events都有自己的gtid_executed。如下所示: binlog.png

总结

由于历史原因,MySQL支持多种存储引擎,也支持多种复制模式,binlog的格式也从statement一种发展到现在的statement、row和mixed三种,为了兼容不同的存储引擎和不同的复制模式,在代码实现上做了很多折衷,这也要求我们要了解历史、了解未来,只有这样才能更好的使用、改进MySQL,为用户提供更好的云服务体验。

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

$
0
0

摘要

本期月报是SQL Server数据库备份技术系列文章的开篇,介绍三种常见的SQL Server备份方法的工作方式、使用T-SQL语句和使用SSMS IDE创建备份集三个层面,介绍SQL Server的三种常见备份的工作原理和使用方法。三种常见的备份包括:

数据库完全备份(Full Backup)

数据库日志备份(Transaction Log Backup)

数据库差异备份(Differential Backup)

备份的重要性

在开始分享之前,我们首先来看看数据库备份的重要性。进入DT时代,数据的价值越发体现,数据已经成为每个公司赖以生存的生命线,数据的重要性不言而喻,而公司绝大多数核心数据都存放在数据库里。数据库本身的灾难恢复(DR)能力是数据安全的最后一道防线,也是数据库从业者对数据安全底线的坚守。数据库中数据潜在的安全风险包括:硬件故障、恶意入侵、用户误操作、数据库损坏和自然灾害导致的数据损失等。在关系型数据库SQL Server中,数据库备份是灾难恢复的能力有力保证。

Full Backup

Full Backup(完全备份)是SQL Server所有备份类型中,最为简单、最基础的数据库备份方法,它提供了某个数据库在备份时间点的完整拷贝。但是,它仅支持还原到数据库备份成功结束的时间点,即不支持任意时间点还原操作。

Full Backup工作方式

以上是Full Backup是什么的解释,那么接下来,我们通过一张图和案例来解释Full Backup的工作原理。 01.png

这是一张某数据库的数据产生以及数据库备份在时间轴上的分布图,从左往右,我们可以分析如下: 7 P.m.:产生了数据#1

10 P.m.:数据库完全备份,备份文件中包含了#1

2 a.m.:产生了数据#2,目前数据包含#1,#2

6 a.m.:产生了数据#3,目前数据包含#1,#2,#3

10 a.m.:数据库完全备份,备份文件中包含#1,#2,#3

1 p.m.:产生了数据#4,目前数据包含#1,#2,#3,#4

5 p.m.:产生了数据#5,目前数据包含#1,#2,#3,#4,#5

8 p.m.:产生了数据#6,目前数据包含#1,#2,#3,#4,#5,#6

10 p.m.:数据库完全备份,备份文件中包含了数据#1,#2,#3,#4,#5,#6

从这张图和相应的解释分析来看,数据库完全备份工作原理应该是非常简单的,它就是数据库在备份时间点对所有数据的一个完整拷贝。当然在现实的生产环境中,事务的操作远比这个复杂,因此,在这个图里面有两个非常重要的点没有展示出来,那就是:

备份操作可能会导致I/O变慢:由于数据库备份是一个I/O密集型操作,所以在数据库备份过程中,可能会导致数据库的I/O操作变慢。

全备份过程中,数据库的事务日志不能够被截断:对于具有大事务频繁操作的数据库,可能会导致事务日志空间一直不停频繁增长,直到占满所有的磁盘剩余空间,这个场景在阿里云RDS SQL产品中有很多的客户都遇到过。其中之一解决方法就需要依赖于我们后面要谈到的事务日志备份技术。

T-SQL创建Full Backup

使用T-SQL语句来完成数据库的完全备份,使用BACKUP DATABASE语句即可,如下,对AdventureWorks2008R2数据库进行一个完全备份:

USE master
GO

BACKUP DATABASE [AdventureWorks2008R2] 
TO DISK = 'C:\Temp\AdventureWorks2008R2_20171112_FULL.bak' WITH COMPRESSION, INIT, STATS = 5;
GO

SSMS IDE创建Full Backup

除了使用T-SQL语句创建数据库的完全备份外,我们还可以使用SSMS IDE界面操作来完成,方法: 右键点击想要备份的数据库 => Tasks => Backup => 选择FULL Backup Type => 选择Disk 做为备份文件存储 => 点击Add 添加备份文件 => 选择你需要存储备份文件的目录 => 输入备份文件名,如下图两张图展示。 02.png

Back up Database设置界面

03.png

Transaction Log Backup

SQL Server数据库完全备份是数据库的完整拷贝,所以备份文件空间占用相对较大,加之可能会在备份过程中导致事务日志一直不断增长。为了解决这个问题,事务日志备份可以很好的解决这个问题,因为:事务日志备份记录了数据库从上一次日志备份到当前时间内的所有事务提交的数据变更,它可以配合数据库完全备份和差异备份(可选)来实现时间点的还原。当日志备份操作成功以后,事务日志文件会被截断,事务日志空间将会被重复循环利用,以此来解决完全备份过程中事务日志文件一致不停增长的问题,因此我们最好能够周期性对数据库进行事务日志备份,以此来控制事务日志文件的大小。但是这里需要有一个前提是数据库必须是FULL恢复模式,SIMPLE恢复模式的数据库不支持事务日志的备份,当然就无法实现时间点的还原。请使用下面的语句将数据库修改为FULL恢复模式,比如针对AdventureWorks2008R2数据库:

USE [master]
GO
ALTER DATABASE [AdventureWorks2008R2] SET RECOVERY FULL WITH NO_WAIT
GO

Transaction Log Backup工作方式

事务日志备份与数据完全备份工作方式截然不同,它不是数据库的一个完整拷贝,而是至上一次日志备份到当前时间内所有提交的事务数据变更。用一张图来解释事务日志备份的工作方式:

04.png

00:01:事务#1,#2,#3开始,未提交

00:02:事务#1,#2,#3成功提交;#4,#5,#6事务开始,未提交;这时备份事务日志;事务日志备份文件中仅包含已提交的#1,#2,#3的事务(图中的LSN 1-4,不包含#4)

00:04:由于在00:02做了事务日志备份,所以#1,#2,#3所占用的空间被回收;#4,#5,#6事务提交完成

00:05:事务#7已经提交成功;#8,#9,#10开始,但未提交;事务日志备份文件中包含#4,#5,#6,#7的事务(图中的LSN4-8,不包含#8)。

从这张图我们看到,每个事务日志备份文件中包含的是已经完成的事务变更,两次事务日志备份中存放的是完全不同的变更数据。而每一次事务日志备份成功以后,事务日志空间可以被成功回收,重复利用,达到了解决数据库完全备份过程中事务日志一致不断增长的问题。

T-SQL创建事务日志备份

使用T-SQL语句来创建事务日志的备份方法如下:

USE Master
GO

BACKUP LOG [AdventureWorks2008R2]
TO DISK = N'C:\temp\AdventureWorks2008R2_log_201711122201.trn' with compression,stats=1;
GO
BACKUP LOG [AdventureWorks2008R2]
TO DISK = N'C:\temp\AdventureWorks2008R2_log_201711122202.trn' with compression,stats=1;
GO
BACKUP LOG [AdventureWorks2008R2]
TO DISK = N'C:\temp\AdventureWorks2008R2_log_201711122203.trn' with compression,stats=1;
GO

SSMS IDE创建事务日志备份

使用SSMS IDE创建事务日志备份的方法: 右键点击想要创建事务日志备份的数据库 => Tasks => Backup => 选择Transaction Log Backup Type => 选择Disk 做为备份文件存储 => 点击Add 添加备份文件 => 选择你需要存储备份文件的目录 => 输入备份文件名,如下图展示:

05.png

事务日志备份链

由于数据库完全备份是时间点数据的完整拷贝,每个数据库完整备份相互独立,而多个事务日志备份是通过事务日志链条连接在一起,事务日志链起点于完全备份,SQL Server中的每一个事务日志备份文件都拥有自己的FirstLSN和LastLSN,FirstLSN用于指向前一个事务日志备份文件的LastLSN;而LastLSN指向下一个日志的FirstLSN,以此来建立这种链接关系。这种链接关系决定了事务日志备份文件还原的先后顺序。当然,如果其中任何一个事务日志备份文件丢失或者破坏,都会导致无法恢复整个事务日志链,仅可能恢复到你拥有的事务日志链条的最后一个。事务日志备份链条的关系如下图所示:

06.png

我们使用前面“T-SQL创建事务日志备份”创建的事务日志链,使用RESTORE HEADERONLY方法来查看事务日志链的关系:

USE Master
GO
RESTORE HEADERONLY FROM DISK = N'C:\temp\AdventureWorks2008R2_log_201711122201.trn';
RESTORE HEADERONLY FROM DISK = N'C:\temp\AdventureWorks2008R2_log_201711122202.trn';
RESTORE HEADERONLY FROM DISK = N'C:\temp\AdventureWorks2008R2_log_201711122203.trn';

查询结果如下:

07.png

从这个结果展示来看,事务日志备份文件AdventureWorks2008R2_log_201711122201的LastLSN指向了的AdventureWorks2008R2_log_201711122202的FirstLSN,而AdventureWorks2008R2_log_201711122202的LastLSN又指向了AdventureWorks2008R2_log_201711122203的FirstLSN,以此来建立了事务日志备份链条关系。假如AdventureWorks2008R2_log_201711122202的事务日志备份文件丢失或者损坏的话,数据库只能还原到AdventureWorks2008R2_log_201711122201所包含的所有事务行为。 这里有一个问题是:为了防止数据库事务日志一直不断的增长,而我们又不想每次都对数据库做完全备份,那么我们就必须对数据库事务日志做周期性的日志备份,比如:5分钟甚至更短,以此来降低数据丢失的风险,以此推算每天会产生24 * 12 = 288个事务日志备份,这样势必会导致事务日志恢复链条过长,拉长恢复时间,增大了数据库还原时间(RTO)。这个问题如何解决就是我们下面章节要分享到的差异备份技术。

Differential Backup

事务日志备份会导致数据库还原链条过长的问题,而差异备份就是来解决事务日志备份的这个问题的。差异备份是备份至上一次数据库全量备份以来的所有变更的数据页,所以差异备份相对于数据库完全备份而言往往数据空间占用会小很多。因此,备份的效率更高,还原的速度更快,可以大大提升我们灾难恢复的能力。

Differential Backup工作方式

我们还是从一张图来了解数据库差异备份的工作方式:

08.png

7 a.m.:数据包含#1

10 a.m.:数据库完全备份,备份文件中包含#1

1 p.m.:数据包含#1,#2,#3,#4

2 p.m.:数据库差异备份,备份文件中包含#2,#3,#4(上一次全备到目前的变更数据)

4 p.m.:数据包含#1,#2,…,#6

6 p.m.:数据库差异备份,备份文件中包含#2,#3,#4,#5,#6

8 p.m.:数据包含#1,#2,…,#8

10 p.m.:数据库完全备份,备份文件中包含#1,#2,…,#8

11 p.m.:产生新的数据#9,#10;数据包含#1,#2,…,#10

2 a.m.:数据库差异备份,备份文件中包含#9,#10

从这个差异备份的工作方式图,我们可以很清楚的看出差异备份的工作原理:它是备份继上一次完全备份以来的所有数据变更,所以它大大减少了备份日之链条的长度和缩小备份集的大小。

T-SQL创建差异备份

使用T-SQL语句创建差异备份的方法如下:

USE master
GO
BACKUP DATABASE [AdventureWorks2008R2] 
TO DISK = 'C:\Temp\AdventureWorks2008R2_20171112_diff.bak' WITH DIFFERENTIAL
GO

SSMS创建差异备份

使用SSMS IDE创建差异备份的方法: 右键点击想要创建事务日志备份的数据库 => Tasks => Backup => 选择Differential Backup Type => 选择Disk 做为备份文件存储 => 点击Add 添加备份文件 => 选择你需要存储备份文件的目录 => 输入备份文件名,如下图展示:

09.png

最后总结

本期月报分享了SQL Server三种常见的备份技术的工作方式和备份方法。数据库完全备份是数据库备份时间的一个完整拷贝;事务日志备份是上一次日志备份到当前时间的事务日志变更,它解决了数据库完全备份过程中事务日志一直增长的问题;差异备份上一次完全备份到当前时间的数据变更,它解决了事务日志备份链过长的问题。 将SQL Server这三种备份方式的工作方式,优缺点总结如下表格:

10.png

从这个表格,我们知道每种备份有其各自的优缺点,那么我们如何来制定我们的备份和还原策略以达到快速灾难恢复的能力呢?这个话题,我们将在下一期月报中进行分享。

参考

Full Backup工作方式图参考

Transaction Log Backup工作方式图参考

Differential Backup工作方式图参考

MySQL · 最佳实践 · 什么时候该升级内存规格

$
0
0

前言

在平时的工作中,会碰到用户想升级规格的case,有一些其实是没有必要的,这些通过优化设计或者改写SQL语句,或者加加索引可以达到不升级的效果,而有一些确实是需要升级规格的,比如今天讲的case。

追根溯源

查看表结构和索引

通过CloudDBA的SQL统计功能,发现SQL比较简单,也有索引,所以排除是这两方面设计的问题。

查看实例性能数据

image.png1

innodb_buffer_pool命中率还不到99%,命中率不高的,而iowait>=2略微高,所以推测是命中率不高,导致数据在内存里换进换出导致。 image.png2

系统层面io对列里面已经有少量的堆积;

查看内存内容

通过查看内存里面的数据和索引的大小,可以看到:

+--------+--------+---------+---------+---------+------------+---------+
| engine | TABLES | rows    | DATA    | idx     | total_size | idxfrac |
+--------+--------+---------+---------+---------+------------+---------+
| InnoDB |  11274 | 899.60M | 247.21G | 187.56G | 434.77G    |    0.76 |
+--------+--------+---------+---------+---------+------------+---------+

数据和索引已经将近440G,而BP却还是1G,更加可以印证上面的推测(数据在内存里面被频繁的换进换出)。 再来验证下: image.png3

过十多分钟再看,BP里面的内容已经不一样了: image.png4

查看实例是如何用的

通过上一步我们可以发现,整个实例的空间是400G+,qps,tps都很低,逻辑读不算高,为什么BP命中率会这么低呢? 通过

mysql>show global variables like "%buffer_pool%";

看到innodb_buffer_pool才1G,所有问题都已经明朗,那么如何解这个问题呢?

解决问题

我们再进一步看这个实例下面其实是有几十个库的,解决这个问题有两种方法:

  1. 直接升级整个实例规格
  2. 拆库

这么大的磁盘空间,又这么低的tps,所以我推荐第2种方法,拆分后其实也相当于变相地达到了升级实例规格的目的。把大实例拆成小实例后,再来看下对比: image.png5

结言

这个case是真正申请的内存规格小了些,所以这个是需要升级内存规格的。 一些小技巧,起到大作用,欢迎使用我们的经验沉淀下来的产品,您随叫随到的数据库专家CloudDBA

MySQL · 源码分析 · InnoDB LRU List刷脏改进之路

$
0
0

之前的一篇内核月报MySQL · 引擎特性 · InnoDB Buffer Pool中对InnoDB Buffer pool的整体进行了详细的介绍。文章已经提到了LRU List以及刷脏的工作原理。本篇文章着重从MySQL 5.7源码层面对LRU List刷脏的工作原理,以及Percona针对MySQL LRU Flush的一些性能问题所做的改进,进行一下分析。

在MySQL中,如果当前数据库需要操作的数据集比Buffer pool中的空闲页面大的话,当前Buffer pool中的数据页就必须进行脏页淘汰,以便腾出足够的空闲页面供当前的查询使用。如果数据库负载太高,对于空闲页面的需求超出了page cleaner的淘汰能力,这时候是否能够快速获取空闲页面,会直接影响到数据库的处理能力。我们将从下面三个阶段来看一下MySQL以及Percona对LRU List刷脏的改进过程。

众所周知,MySQL操作任何一个数据页面都需要读到Buffer pool进行才会进行操作。所以任何一个读写请求都需要从Buffer pool来获取所需页面。如果需要的页面已经存在于Buffer pool,那么直接利用当前页面进行操作就行。但是如果所需页面不在Buffer pool,比如UPDATE操作,那么就需要从Buffer pool中新申请空闲页面,将需要读取的数据放到Buffer pool中进行操作。那么官方MySQL 5.7.4之前的版本如何从buffer pool中获取一个页面呢?请看如下代码段:


buf_block_t*
buf_LRU_get_free_block(
/*===================*/
  buf_pool_t* buf_pool) /*!< in/out: buffer pool instance */
{
  buf_block_t*  block   = NULL;
  bool    freed   = false;
  ulint   n_iterations  = 0; 
  ulint   flush_failures  = 0; 
  bool    mon_value_was = false;
  bool    started_monitor = false;

  MONITOR_INC(MONITOR_LRU_GET_FREE_SEARCH);
loop:
  buf_pool_mutex_enter(buf_pool); // 这里需要对当前buf_pool使用mutex,存在锁竞争

  // 当前函数会检查一些非数据对象,比如AHI, lock 所占用的buf_pool是否太高并发出警告
  buf_LRU_check_size_of_non_data_objects(buf_pool);

  /* If there is a block in the free list, take it */
  block = buf_LRU_get_free_only(buf_pool);

  // 如果获取到了空闲页面,清零之后就直接使用。否则就需要进行LRU页面淘汰
  if (block != NULL) {

    buf_pool_mutex_exit(buf_pool);
    ut_ad(buf_pool_from_block(block) == buf_pool);
    memset(&block->page.zip, 0, sizeof block->page.zip);

    if (started_monitor) {
      srv_print_innodb_monitor =
        static_cast<my_bool>(mon_value_was);
    }

    block->skip_flush_check = false;
    block->page.flush_observer = NULL;
    return(block);
  }

  MONITOR_INC( MONITOR_LRU_GET_FREE_LOOPS );

  freed = false;
  /**
    这里会重复进行空闲页扫描,如果没有空闲页面,会根据LRU list对页面进行淘汰。
    这里设置buf_pool->try_LRU_scan是做了一个优化,如果当前用户线程扫描的时候
    发现没有空闲页面,那么其他用户线程就不需要进行同样的扫描。
  */

  if (buf_pool->try_LRU_scan || n_iterations > 0) {
    /* If no block was in the free list, search from the
    end of the LRU list and try to free a block there.
    If we are doing for the first time we'll scan only
    tail of the LRU list otherwise we scan the whole LRU
    list. */
    freed = buf_LRU_scan_and_free_block(
      buf_pool, n_iterations > 0);

    if (!freed && n_iterations == 0) {
      /* Tell other threads that there is no point
      in scanning the LRU list. This flag is set to
      TRUE again when we flush a batch from this
      buffer pool. */
      buf_pool->try_LRU_scan = FALSE;
    }
  }

  buf_pool_mutex_exit(buf_pool);

  if (freed) {
    goto loop;
  }

  if (n_iterations > 20
      && srv_buf_pool_old_size == srv_buf_pool_size) {
	// 如果循环获取空闲页的次数大于20次,系统将发出报警信息
      ...
}
 /* If we have scanned the whole LRU and still are unable to
  find a free block then we should sleep here to let the
  page_cleaner do an LRU batch for us. */

  if (!srv_read_only_mode) {
    os_event_set(buf_flush_event);
  }

  if (n_iterations > 1) {

    MONITOR_INC( MONITOR_LRU_GET_FREE_WAITS );
	// 这里每次循环释放空闲页面会间隔10ms
    os_thread_sleep(10000);
  }

  /* 如果buffer pool里面没有发现可以直接替换的页面(所谓直接替换的页面,
    是指页面没有被修改, 也没有别的线程进行引用,同时当前页已经被载入buffer pool),
    注意:上面的页面淘汰过程至少会尝试
    innodb_lru_scan_depth个页面。如果上面不存在可以淘汰的页面。那么系统将尝试淘汰一个
    脏页面(可替换页面或者已经被载入buffer pool的脏页面)。
  */
  if (!buf_flush_single_page_from_LRU(buf_pool)) {
    MONITOR_INC(MONITOR_LRU_SINGLE_FLUSH_FAILURE_COUNT);
    ++flush_failures;
  }

  srv_stats.buf_pool_wait_free.add(n_iterations, 1);

  n_iterations++;

  goto loop;
}



从上面获取一个空闲页的源码逻辑可以看出,buf_LRU_get_free_block会循环尝试去淘汰LRU list上的页面。每次循环都会去访问free list,查看是否有足够的空闲页面。如果没有将继续从LRU list去淘汰。这样的循环在负载比较高的情况下,会加剧对free list以及LRU list的mutex竞争。

MySQL空闲页面的获取依赖于page cleaner的刷新能力,如果page cleaner不能即时的刷新足够的空闲页面,那么系统就会使用上面的逻辑来为用户线程申请空闲页面。但如果让page cleaner加快刷新,又会导致频繁刷新脏数据,引发性能问题。 为了改善系统负载太高的情况下,page cleaner刷脏能力不足,进而用户线程调用LRU刷脏导致锁竞争加剧影响数据库性能,Percona对此进行了改善,引入独立的线程负责LRU list的刷脏。目的是为了让独立线程根据系统负载动态调整LRU的刷脏能力。由于LRU list的刷脏从page cleaner线程中脱离出来,调整LRU list的刷脏能力不再会影响到page cleaner。下面我们看一下相关的源码:

/**
  该函数会根据系统的负载情况,或者是buffer pool的空闲页面的情况来动态调整lru_manager_thread的  刷脏能力。
*/
static
void
lru_manager_adapt_sleep_time(
/*==============================*/
  ulint*  lru_sleep_time) /*!< in/out: desired page cleaner thread sleep
        time for LRU flushes  */
{
  /* 实际的空闲页 */
  ulint free_len = buf_get_total_free_list_length();
  /* 期望至少保持的空闲页 */
  ulint max_free_len = srv_LRU_scan_depth * srv_buf_pool_instances;

  /* 下面的逻辑会根据当前的空闲页面与期望的空闲页面之间的比对,
    来调整lru_manager_thread的刷脏频率
  */
  if (free_len < max_free_len / 100) {

    /* 实际的空闲页面小于期望的1%,系统会触使lru_manager_thread不断刷脏。*/
    *lru_sleep_time = 0;
  } else if (free_len > max_free_len / 5) {

    /* Free lists filled more than 20%, sleep a bit more */
    *lru_sleep_time += 50;
    if (*lru_sleep_time > srv_cleaner_max_lru_time) {
      *lru_sleep_time = srv_cleaner_max_lru_time;
    }
  } else if (free_len < max_free_len / 20 && *lru_sleep_time >= 50) {

    /* Free lists filled less than 5%, sleep a bit less */
    *lru_sleep_time -= 50;
  } else {

    /* Free lists filled between 5% and 20%, no change */
  }
}

extern "C" UNIV_INTERN
os_thread_ret_t
DECLARE_THREAD(buf_flush_lru_manager_thread)(
/*==========================================*/
  void* arg __attribute__((unused)))
      /*!< in: a dummy parameter required by
      os_thread_create */
{
  ulint next_loop_time = ut_time_ms() + 1000;
  ulint lru_sleep_time = srv_cleaner_max_lru_time;

#ifdef UNIV_PFS_THREAD
  pfs_register_thread(buf_lru_manager_thread_key);
#endif /* UNIV_PFS_THREAD */

#ifdef UNIV_DEBUG_THREAD_CREATION
  fprintf(stderr, "InnoDB: lru_manager thread running, id %lu\n",
    os_thread_pf(os_thread_get_curr_id()));
#endif /* UNIV_DEBUG_THREAD_CREATION */

  buf_lru_manager_is_active = true;
  /* On server shutdown, the LRU manager thread runs through cleanup
  phase to provide free pages for the master and purge threads.  */
  while (srv_shutdown_state == SRV_SHUTDOWN_NONE
         || srv_shutdown_state == SRV_SHUTDOWN_CLEANUP) {
    /* 根据系统负载情况,动态调整lru_manager_thread的工作频率 */
    lru_manager_sleep_if_needed(next_loop_time);

    lru_manager_adapt_sleep_time(&lru_sleep_time);

    next_loop_time = ut_time_ms() + lru_sleep_time;

    /**
      这里lru_manager_thread轮询每个buffer pool instances,尝试从LRU的尾部开始淘汰            innodb_lru_scan_depth个页面
    */
    buf_flush_LRU_tail();
  }

  buf_lru_manager_is_active = false;

  os_event_free(buf_lru_event);
  /* We count the number of threads in os_thread_exit(). A created
  thread should always use that to exit and not use return() to exit. */
  os_thread_exit(NULL);

  OS_THREAD_DUMMY_RETURN;
}

从上面的源码可以看到,LRU list的刷脏依赖于LRU_mangager_thread, 当然正常的page cleaner也会对LRU list进行刷脏。但是整个Buffer pool的所有instances都依赖于一个LRU list刷脏线程,负载比较高的情况下也很有可能成为瓶颈。

官方MySQL 5.7版本为了缓解单个page cleaner线程进行刷脏的压力,在5.7.4中引入了multiple page cleaner threads这个feature,用来增强刷脏速度,但是从下面的测试可以发现,即便是multiple page cleaner threads在高负载的情况下,还是会对系统性能有影响。下面的测试结果也显示了性能方面受到的影响。

5.7-mpc.png

就multiple page cleaner刷脏能力受到限制,主要是因为存在以下问题: 1) LRU List刷脏在先,Flush list的刷脏在后,但是是互斥的。也就是说在进Flush list刷脏的时候,LRU list不能继续去刷脏,必须等到下一个循环周期才能进行。 2) 另外一个问题就是,刷脏的时候,page cleaner coodinator会等待所有的page cleaner线程完成之后才会继续响应刷脏请求。这带来的问题就是如果某个buffer pool instance比较热的话,page cleaner就不能及时进行响应。

针对上面的问题,Percona改进了原来的单线程LRU list刷脏的方式,继续将LRU list独立于page cleaner threads并将LRU list单线程刷脏增加为多线程刷脏。page cleaner只负责flush list的刷脏,lru_manager_thread只负责LRU List刷脏。这样的分离,可以使得LRU list刷脏和Flush List刷脏并行执行。看一下修改之后的测试情况:

pc-mlf.png

下面用Multiple LRU list flush threads的源码patch简单介绍一下Percona所做的更改。

@@ -2922,26 +2876,12 @@ pc_flush_slot(void)
    }    
 
    if (!page_cleaner->is_running) {
-     slot->n_flushed_lru = 0;
      slot->n_flushed_list = 0; 
      goto finish_mutex;
    }    
 
    mutex_exit(&page_cleaner->mutex);
 
/* 这里的patch可以看出LRU list的刷脏从page cleaner线程里隔离开来 */
-   lru_tm = ut_time_ms();
-
-   /* Flush pages from end of LRU if required */
-   slot->n_flushed_lru = buf_flush_LRU_list(buf_pool);
-
-   lru_tm = ut_time_ms() - lru_tm;
-   lru_pass++;
-

@@ -1881,6 +1880,13 @@ innobase_start_or_create_for_mysql(void)
         NULL, NULL);
  }
/* 这里在MySQL启动的时候,会同时启动和Buffer pool instances同样数量的LRU list刷脏线程。 */
+ for (i = 0; i < srv_buf_pool_instances; i++) {
/* 这里每个LRU list线程负责自己对应的Buffer pool instance的LRU list刷脏 */
+   os_thread_create(buf_lru_manager, reinterpret_cast<void *>(i),
+        NULL);
+ }
+
+ buf_lru_manager_is_active = true;
+

综上所述,本篇文章主要从源码层面对Percona以及官方对于LRU list刷脏方面所做的改进进行了分析。Percona对于LRU list刷脏问题做了很大的贡献。从测试结果可以看到,如果负载较高,空闲页不足的情况下,Percona的改进起到了明显的作用。


MySQL · 特性分析 · MySQL 5.7 外部XA Replication实现及缺陷分析

$
0
0

MySQL 5.7 外部XA Replication实现及缺陷分析

MySQL 5.7增强了分布式事务的支持,解决了之前客户端退出或者服务器关闭后prepared的事务回滚和服务器宕机后binlog丢失的情况。

为了解决之前的问题,MySQL5.7将外部XA在binlog中的记录分成了两部分,使用两个GTID来记录。执行prepare的时候就记录一次binlog,执行commit/rollback再记录一次。由于XA是分成两部分记录,那么XA事务在binlog中就可能是交叉出现的。Slave端的SQL线程在apply的时候需要能够在这些不同事务间切换。

但MySQL XA Replication的实现只考虑了Innodb一种事务引擎的情况,当添加其他事务引擎的时候,原本的一些代码逻辑就会有问题。同时MySQL源码中也存在宕机导致主备不一致的缺陷。

MySQL 5.7 外部XA Replication源码剖析

Master写入

当执行 XA START ‘xid’后,内部xa_state进入XA_ACTIVE状态。

bool Sql_cmd_xa_start::trans_xa_start(THD *thd)
{
          xid_state->set_state(XID_STATE::XA_ACTIVE);

第一次记录DML操作的时候,通过下面代码可以看到,对普通事务在binlog的cache中第一个event记录’BEGIN’,如果是xa_state处于XA_ACTIVE状态就记录’XA START xid’,xid为序列化后的。

static int binlog_start_trans_and_stmt(THD *thd, Log_event *start_event)
{
  if (cache_data->is_binlog_empty())
  {
    if (is_transactional && xs->has_state(XID_STATE::XA_ACTIVE))
    {
      /*
        XA-prepare logging case.
        */
        qlen= sprintf(xa_start, "XA START %s", xs->get_xid()->serialize(buf));
        query= xa_start;
      }
      else
      {
        /*
        Regular transaction case.
        */
        query= begin;
      }

      Query_log_event qinfo(thd, query, qlen,
                          is_transactional, false, true, 0, true);
      if (cache_data->write_event(thd, &qinfo))
        DBUG_RETURN(1);

XA END xid的执行会将xa_state设置为XA_IDLE。

bool Sql_cmd_xa_end::trans_xa_end(THD *thd)
{
  xid_state->set_state(XID_STATE::XA_IDLE);

当XA PREPARE xid执行的时候,binlog_prepare会通过检查thd的xa_state是否处于XA_IDLE状态来决定是否记录binlog。如果在对应状态,就会调用MYSQL_BINLOG的commit函数,记录’XA PREPARE xid’,将之前cache的binlog写入到文件。

static int binlog_prepare(handlerton *hton, THD *thd, bool all)
{
  DBUG_RETURN(all && is_loggable_xa_prepare(thd) ?
              mysql_bin_log.commit(thd, true) : 0);


inline bool is_loggable_xa_prepare(THD *thd)
{
  return DBUG_EVALUATE_IF("simulate_commit_failure",
                          false,
                          thd->get_transaction()->xid_state()->
                          has_state(XID_STATE::XA_IDLE));

TC_LOG::enum_result MYSQL_BIN_LOG::commit(THD *thd, bool all)
{
      if (is_loggable_xa_prepare(thd))
    {
      XID_STATE *xs= thd->get_transaction()->xid_state();
      XA_prepare_log_event end_evt(thd, xs->get_xid(), one_phase);
      err= cache_mngr->trx_cache.finalize(thd, &end_evt, xs)
    }

当XA COMMIT/ROLLBACK xid执行时候,调用do_binlog_xa_commit_rollback记录’XA COMMIT/ROLLBACK xid’。

TC_LOG::enum_result MYSQL_BIN_LOG::commit(THD *thd, bool all)
{
  if (thd->lex->sql_command == SQLCOM_XA_COMMIT)
    do_binlog_xa_commit_rollback(thd, xs->get_xid(),
                                true)))

int MYSQL_BIN_LOG::rollback(THD *thd, bool all)
{
  if (thd->lex->sql_command == SQLCOM_XA_ROLLBACK)
    if ((error= do_binlog_xa_commit_rollback(thd, xs->get_xid(), false)))

由于XA PREPARE单独记录binlog,那么binlog中的events一个xa事务就可能是分隔开的。举个例子,session1中xid为’a’的分布式事务执行xa prepare后,session2中执行并提交了xid为’z’的事务,然后xid ‘a’才提交。我们可以看到binlog events中xid ‘z’的events在’a’的prepare和commit之间。

session1:
xa start 'a';
insert into t values(1);
xa end 'a';
xa prepare 'a';

session2:
xa start 'z';
insert into t values(2);
xa end 'z';
xa prepare 'z';
xa commit 'z';

session1:
xa commit 'a';


| mysql-bin.000008 |  250 | Gtid           |       324 |         298 | SET @@SESSION.GTID_NEXT= 'uuid:9'  |
| mysql-bin.000008 |  298 | Query          |       324 |         385 | XA START X'61',X'',1               |
| mysql-bin.000008 |  385 | Table_map      |       324 |         430 | table_id: 72 (test.t)              |
| mysql-bin.000008 |  430 | Write_rows_v1  |       324 |         476 | table_id: 72 flags: STMT_END_F     |
| mysql-bin.000008 |  476 | Query          |       324 |         561 | XA END X'61',X'',1                 |
| mysql-bin.000008 |  561 | XA_prepare     |       324 |         598 | XA PREPARE X'61',X'',1             |
| mysql-bin.000008 |  598 | Gtid           |       324 |         646 | SET @@SESSION.GTID_NEXT= 'uuid:10' |
| mysql-bin.000008 |  646 | Query          |       324 |         733 | XA START X'7a',X'',1               |
| mysql-bin.000008 |  733 | Table_map      |       324 |         778 | table_id: 72 (test.t)              |
| mysql-bin.000008 |  778 | Write_rows_v1  |       324 |         824 | table_id: 72 flags: STMT_END_F     |
| mysql-bin.000008 |  824 | Query          |       324 |         909 | XA END X'7a',X'',1                 |
| mysql-bin.000008 |  909 | XA_prepare     |       324 |         946 | XA PREPARE X'7a',X'',1             |
| mysql-bin.000008 |  946 | Gtid           |       324 |         994 | SET @@SESSION.GTID_NEXT= 'uuid:11' |
| mysql-bin.000008 |  994 | Query          |       324 |        1082 | XA COMMIT X'7a',X'',1              |
| mysql-bin.000008 | 1082 | Gtid           |       324 |        1130 | SET @@SESSION.GTID_NEXT= 'uuid:12' |
| mysql-bin.000008 | 1130 | Query          |       324 |        1218 | XA COMMIT X'61',X'',1              |

Slave 重放

由于XA事务在binlog中是会交叉出现的,Slave的SQL线程如果按照原本普通事务的方式重放,那么就会出现SQL线程中还存在处于prepared状态的事务,就开始处理下一个事务了,锁状态、事务状态等会错乱。所以SQL线程需要能够支持这种情况下不同事务间的切换。

SQL线程要做到能够在执行XA事务时切换到不同事务,需要做到server层保留原有xid的Transaction_ctx信息,引擎层也保留原有xid的事务信息。

server层保留原有xid的Transaction_ctx信息是通过在prepare的时候将thd中xid的Transaction_ctx信息从transacion_cache中detach掉,创建新的保留了XA事务信息的Transaction_ctx放入transaction_cache中。

bool Sql_cmd_xa_prepare::execute(THD *thd)
    !(st= applier_reset_xa_trans(thd)))

bool applier_reset_xa_trans(THD *thd)
    transaction_cache_detach(trn_ctx);

bool transaction_cache_detach(Transaction_ctx *transaction)
  res= create_and_insert_new_transaction(&xid, was_logged);

引擎层的实现并不是通过在prepare的时候创建新trx_t的来保存原有事务信息。而是在XA START的时候将原来thd中所有的engine ha_data单独保留起来,为XA事务创建新的。在XA PREPARE的时候,再将原来的reattach回来,将XA的从thd detach掉,解除XA和thd的关联。引擎层添加了新的接口replace_native_transaction_in_thd来支持上述操作。对于Slave的SQL线程,函数调用如下:

//engine 新添加的接口
struct handlerton
{
  void (*replace_native_transaction_in_thd)(THD *thd, void *new_trx_arg, void **ptr_trx_arg);

//XA START函数调用
bool Sql_cmd_xa_start::execute(THD *thd)
{
    thd->rpl_detach_engine_ha_data();

void THD::rpl_detach_engine_ha_data()
{
  rli->detach_engine_ha_data(this);

//每个Storage engine都调用detach_native_trx
void Relay_log_info::detach_engine_ha_data(THD *thd)
{
  plugin_foreach(thd, detach_native_trx,
                 MYSQL_STORAGE_ENGINE_PLUGIN, NULL);

my_bool detach_native_trx(THD *thd, plugin_ref plugin, void *unused)
{
  if (hton->replace_native_transaction_in_thd)
    hton->replace_native_transaction_in_thd(thd, NULL,
                                            thd_ha_data_backup(thd, hton));

//XA PREPARE函数调用
bool Sql_cmd_xa_prepare::execute(THD *thd)
{
  !(st= applier_reset_xa_trans(thd)))

bool applier_reset_xa_trans(THD *thd)
{
  attach_native_trx(thd);

//对事务涉及到的引擎调用reattach_engine_ha_data_to_thd。
static void attach_native_trx(THD *thd)
{
  if (ha_info)
  {
    for (; ha_info; ha_info= ha_info_next)
    {
      handlerton *hton= ha_info->ht();
      reattach_engine_ha_data_to_thd(thd, hton);
      ha_info_next= ha_info->next();
      ha_info->reset();
    }
  }

inline void reattach_engine_ha_data_to_thd(THD *thd, const struct handlerton *hton)
{
  if (hton->replace_native_transaction_in_thd)
    hton->replace_native_transaction_in_thd(thd, *trx_backup, NULL);

当XA COMMIT/ROLLBACK执行的时候,如果当前thd中没有对应的xid,就会从transaction_cache中查找对应xid的state信息,然后调用各个引擎的commit_by_xid/rollback_by_xid接口提交/回滚XA事务。

bool Sql_cmd_xa_commit::trans_xa_commit(THD *thd)
{
  if (!xid_state->has_same_xid(m_xid))
  {
        Transaction_ctx *transaction= transaction_cache_search(m_xid);
        ha_commit_or_rollback_by_xid(thd, m_xid, !res);

static void ha_commit_or_rollback_by_xid(THD *thd, XID *xid, bool commit)
{
  plugin_foreach(NULL, commit ? xacommit_handlerton : xarollback_handlerton,
                 MYSQL_STORAGE_ENGINE_PLUGIN, xid);

static my_bool xacommit_handlerton(THD *unused1, plugin_ref plugin, void *arg)
{
  if (hton->state == SHOW_OPTION_YES && hton->recover)
    hton->commit_by_xid(hton, (XID *)arg);

static my_bool xarollback_handlerton(THD *unused1, plugin_ref plugin, void *arg)
{
  if (hton->state == SHOW_OPTION_YES && hton->recover)
    hton->rollback_by_xid(hton, (XID *)arg);                               

由于XA COMMIT/XA ROLLBACK是单独作为一部分,这部分并没有原来XA事务涉及到库、表的信息,所以XA COMMIT在Slave端当slave-parallel-type为DATABASE时是无法并发执行的,在slave端强制设置mts_accessed_dbs为OVER_MAX_DBS_IN_EVENT_MTS使其串行执行。

bool Log_event::contains_partition_info(bool end_group_sets_max_dbs)
{
  case binary_log::QUERY_EVENT:
  {
    Query_log_event *qev= static_cast<Query_log_event*>(this);
    if ((ends_group() && end_group_sets_max_dbs) ||
      (qev->is_query_prefix_match(STRING_WITH_LEN("XA COMMIT")) ||
      qev->is_query_prefix_match(STRING_WITH_LEN("XA ROLLBACK"))))
      {
        res= true;
        qev->mts_accessed_dbs= OVER_MAX_DBS_IN_EVENT_MTS;
      }

MySQL5.7 外部XA Replication实现的缺陷分析

Prepare阶段可能导致主备不一致

MySQL中普通事务提交的时候,需要先在引擎中prepare,然后再写binlog,之后再做引擎commit。但在MySQL执行XA PREPARE的时候先写入了binlog,然后才做引擎的prepare。如果引擎在做prepare的时候失败或者服务器crash就会导致binlog和引擎不一致,主备进入不一致的状态。

在MySQL5.7中对模拟simulate_xa_failure_prepare的DEBUG情况做如下修改,使之模拟在Innodb引擎prepare的时候失败。

--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1460,10 +1460,12 @@ int ha_prepare(THD *thd)
       thd->status_var.ha_prepare_count++;
       if (ht->prepare)
       {
-        DBUG_EXECUTE_IF("simulate_xa_failure_prepare", {
-          ha_rollback_trans(thd, true);
-          DBUG_RETURN(1);
-        });
+        if (ht->db_type == DB_TYPE_INNODB) {
+          DBUG_EXECUTE_IF("simulate_xa_failure_prepare", {
+            ha_rollback_trans(thd, true);
+            DBUG_RETURN(1);
+          });
+        }
         if (ht->prepare(ht, thd, true))
         {
           ha_rollback_trans(thd, true);

然后运行下面的case,可以看到Master上的XA失败后被回滚。但由于这个时候已经写入了binlog events,导致Slave端执行了XA事务,留下一个处于prepared状态的XA事务。

replication.test:

--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,simulate_xa_failure_prepare';
--error ER_XA_RBROLLBACK
XA PREPARE 'x';
--echo #Master
XA RECOVER;

--sync_slave_with_master
connection slave;
--echo #Slave
XA RECOVER;


replication.result:

include/master-slave.inc
[connection master]
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,simulate_xa_failure_prepare';
XA PREPARE 'x';
ERROR XA100: XA_RBROLLBACK: Transaction branch was rolled back
#Master
XA RECOVER;
formatID  gtrid_length  bqual_length  data
#Slave
XA RECOVER;
formatID  gtrid_length  bqual_length  data
1 1 0 x

在MySQL5.7源码中,如果在binlog和InnoDB引擎都prepare之后是不是数据就安全了呢?我们在ha_prepare函数中while循环调用完所有引擎prepare函数之后添加如下DEBUG代码,可以控制在prepare调用结束后服务器crash掉。

--- a/sql/handler.cc
+++ b/sql/handler.cc
@@ -1479,6 +1479,7 @@ int ha_prepare(THD *thd)
       }
       ha_info= ha_info->next();
     }
+    DBUG_EXECUTE_IF("crash_after_xa_prepare", DBUG_SUICIDE(););

     DBUG_ASSERT(thd->get_transaction()->xid_state()->
                 has_state(XID_STATE::XA_IDLE));

然后跑下面的testcase。可以看到即使所有引擎都prepare了,宕机重启后XA RECOVER还是还是没有能够找回之前prepare的事务。而且这个时候我们查看binlog文件可以看到binlog已经写成功,这也会导致主备不一致。很明显,应该是InnoDB引擎丢失了prepare的日志。那么是什么原因导致这个问题?感兴趣的同学可以查看int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit)和innobase中trx_prepare的代码,看process_flush_stage_queue和flush_logs和thd->durability_property的相关逻辑。这里不再展开详细叙述。

replication.test:

-- source include/have_log_bin.inc
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,crash_after_xa_prepare';
--exec echo "wait"> $MYSQLTEST_VARDIR/tmp/mysqld.1.expect
--error 2013
XA PREPARE 'x';
--source include/wait_until_disconnected.inc
--let $_expect_file_name= $MYSQLTEST_VARDIR/tmp/mysqld.1.expect
--source include/start_mysqld.inc
XA RECOVER;
show binlog events in 'mysql.000001';


replication.result:
CREATE TABLE ti (c1 INT) ENGINE=INNODB;
XA START 'x';
INSERT INTO ti VALUES(1);
XA END 'x';
SET @@session.debug = '+d,crash_after_xa_prepare';
XA PREPARE 'x';
ERROR HY000: Lost connection to MySQL server during query
# restart
XA RECOVER;
formatID  gtrid_length  bqual_length  data
show binlog events in 'mysql.000001';
Log_name  Pos Event_type  Server_id End_log_pos Info
mysql.000001  4 Format_desc 1 123 Server ver: 5.7.19org-debug-log, Binlog ver: 4
mysql.000001  123 Previous_gtids  1 154
mysql.000001  154 Anonymous_Gtid  1 219 SET @@SESSION.GTID_NEXT= 'ANONYMOUS'
mysql.000001  219 Query 1 331 use `test`; CREATE TABLE ti (c1 INT) ENGINE=INNODB
mysql.000001  331 Anonymous_Gtid  1 396 SET @@SESSION.GTID_NEXT= 'ANONYMOUS'
mysql.000001  396 Query 1 483 XA START X'78',X'',1
mysql.000001  483 Table_map 1 528 table_id: 222 (test.ti)
mysql.000001  528 Write_rows  1 568 table_id: 222 flags: STMT_END_F
mysql.000001  568 Query 1 653 XA END X'78',X'',1
mysql.000001  653 XA_prepare  1 690 XA PREPARE X'78',X'',1

上面两个问题的修复,都可以通过先执行事务引擎的prepare操作,再调用binlog的prepare来解决。

不支持server中使用多个事务引擎

在上面实现分析中可以看到Slave在执行XA START的时候,由于这个时候并不知道该XA事务涉及到哪些引擎,所以对所有Storage engine引擎都调用了detach_native_trx。但在XA PREPARE的时候,源码中只对XA涉及到的引擎调用了reattach_engine_ha_data_to_thd。对于引擎可插拔的MySQL来说,当server中不止一个事务引擎,这里就会存在有的引擎原thd中的trx被detach后没有被reattach。

我们可以拿支持tokudb的percona server做对应实验。对DEBUG编译的server,执行下面replication的testcase。该case对TokuDB做一个完整的XA事务后,再向Innodb写入。运行该case,slave端会产生assert_fail的错误。因为TokuDB执行XA事务时,将Innodb的ha_data放入backup,但由于Innodb没有参与该XA事务,所以并没有reattach,导致gdb可以看到assert_fail处InnoDB的ha_ptr_backup不为NULL,不符合预期。

replication.test
--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
create table tk(c1 int) engine=tokudb;
create table ti(c1 int) engine=innodb;

xa start 'x';
insert into tk values(1);
xa end 'x';
xa prepare 'x';
xa commit 'x';

insert into ti values(2);

__assert_fail
thd->ha_data[ht_arg->slot].ha_ptr_backup == __null || (thd->get_transaction()->xid_state()-> has_state(XID_STATE::XA_ACTIVE))"

(gdb) p thd->ha_data[ht_arg->slot].ha_ptr_backup
$1 = (void *) 0x2b11e0401070

修复问题,可以在需要reattach_engine_ha_data_to_thd的代码处,对所有storage engine再次调用该操作。

不支持新接口的事务引擎重放新XA事务会出错

对于不支持reattach_engine_ha_data_to_thd的事务引擎实际是不支持重放MySQL5.7新XA方式生成的binlog的,但在源码中并没有合适禁止操作。这就会导致slave在apply的时候数据错乱。

继续使用支持tokudb的percona server做实验。由于TokuDB并没有实现reattach_engine_ha_data_to_thd接口,Slave在重放XA事务的时候,在TokuDB引擎中实际就在原本关联thd的trx上操作,并没有生成新的trx。这就会导致数据等信息错乱,可以看到下面的例子。session1做了一个XA事务,插入数值1,prepare后并没有提交。随后另一个session插入数值2,但在slave同步后,数值2无法查询到。在session1提交了XA事务,写入TokuDB的数值1、2才在slave端查询到。

replication.test:

--disable_warnings
source include/master-slave.inc;
--enable_warnings
connection master;
--echo #Master
create table tk(c1 int) engine=tokudb;
xa start 'x';
insert into tk values(1);
xa end 'x';
xa prepare 'x';
connect(m, localhost, root, , test, $MASTER_MYPORT);
insert into tk values(2);
select * from tk;

--sync_slave_with_master
connection slave;
--echo #Slave
select * from tk;

connection master;
--echo #Master
xa commit 'x';
select * from tk;

--sync_slave_with_master
connection slave;
--echo #Slave
select * from tk;

connection master;
drop table tk;



replication.result:

include/master-slave.inc
[connection master]
#Master
create table tk(c1 int) engine=tokudb;
xa start 'x';
insert into tk values(1);
xa end 'x';
xa prepare 'x';
insert into tk values(2);
select * from tk;
c1
2
#Slave
select * from tk;
c1
#Master
xa commit 'x';
select * from tk;
c1
1
2
#Slave
select * from tk;
c1
1
2
drop table tk;

修复该问题,需要对没有实现新接口的事务引擎在执行XA时候给与合适的禁止操作,同时需要支持新XA的事务引擎要实现reattach_engine_ha_data_to_thd接口。

PgSQL · 最佳实践 · 双十一数据运营平台订单Feed数据洪流实时分析方案

$
0
0

摘要

2017年的双十一又一次刷新了记录,交易创建峰值32.5万笔/秒、支付峰值25.6万笔/秒。而这样的交易和支付等记录,都会形成实时订单Feed数据流,汇入数据运营平台的主动服务系统中去。

数据运营平台的主动服务,根据这些合并后的数据,实时的进行分析,进行实时的舆情展示,实时的找出需要主动服务的对象等,实现一个智能化的服务运营平台。

通过阿里云RDS PostgreSQL和HybridDB for PGSQL实时分析方案:

  • 承受住了几十万笔/s的写入吞吐并做数据清洗,是交易的数倍
  • 实现分钟级延迟的实时分析,5张十亿级表关联秒级响应
  • 实时发现交易异常,提升淘宝的用户体验。

业务背景

一个电商业务通常会涉及 商家、门店、物流、用户、支付渠道、贷款渠道、商品、平台、小二、广告商、厂家、分销商、店主、店员、监管员、税务、质检等等角色,这些对象的活动会产生大量的 浏览、订单、投诉、退款、纠纷等业务数据。

而任何一笔业务,都会涉及很多不同的业务系统。在这些业务系统中,为了定位问题、运营需要、分析需要或者其他需求,会在系统中设置埋点,记录用户的行为在业务系统中产生的日志,也叫FEED日志。比如订单系统、在业务系统中环环相扣,从购物车、下单、付款、发货,收货(还有纠纷、退款等等),一笔订单通常会产生若干相关联的记录。

每个环节产生的属性可能是不一样的,有可能有新的属性产生,也有可能变更已有的属性值。为了便于分析,通常有必要将订单在整个过程中产生的若干记录(若干属性),合并成一条记录(订单大宽表)。

数据运营平台的主动服务,根据这些合并后的数据,实时的进行分析,进行实时的舆情展示,实时的找出需要主动服务的对象等,实现一个智能化的服务运营平台。

难点

该项目不止业务逻辑复杂,实时性和性能要求都极高。具体的有:

  • 复杂查询的极限性能,比如5张过十亿的表关联
  • 实时性,要求分钟级以内的延迟
  • 高并发写入
  • 吞吐压力高达每秒几十万笔/s
  • 每个SQL分析的数据总是在TB级
  • 响应时间要求秒级、毫秒级

除了实时性的要求以外,在写入的过程中,还有数据的切换、合并和清理等动作。做过数据库或数据分析的会知道:单独要做到每秒80万/s吞吐的写入、切换、合并和清理并不算特别困难;单独要做到TB级数据的毫秒级分析也不算困难。

但要做到实时写入的同时提供分钟级延迟的秒级实时分析,并做合理的调度就没那么容易了。

方案

为支撑这样的业务需求,采用的方案图示如下:

HTAP.jpeg

其中:

  • RDS PostgreSQL 是阿里云基于开源关系型数据库PostgreSQL开发的云上版本
  • HybridDB for PostgreSQL是MPP架构的分布式分析型数据库,在多表关联、复杂查询、实时统计、圈人等诸多方面性能卓越,并支持JSON、GIS、HLL估值等多种独特的功能特性
  • OSS,是阿里云推出的海量、安全、低成本、高可靠的云存储服务,此处用作数据的离线存储
  • 最关键的,是实现RDS PostgreSQL和HybridDB for PostgreSQL 对离线存储OSS的透明化访问能力

在该方案中,多个PostgreSQL接受业务的写入,在每个RDS PostgreSQL中完成数据的清洗,然后以操作外部表(类似堆表)的方式,将清洗完的数据写入弹性存储OSS;而在写入完成后,HybridDB for PostgreSQL 也以操作外部表(类似堆表)的方式,从OSS中将数据并行加载到HybridDB中。在HybridDB中,实现几十、几百TB级数据的毫秒级查询。

在PostgreSQL中,创建一个外部表:

# 创建插件,每个库执行一次
create extension oss_fdw;

# 创建 server,每个OSS bucket创建一个
CREATE SERVER ossserver FOREIGN DATA WRAPPER oss_fdw OPTIONS 
     (host 'oss-cn-hangzhou-zmf.aliyuncs.com' , id 'xxx', key 'xxx',bucket 'mybucket');

# 创建 oss 外部表,每个需要操作的OSS对象对应一张表
CREATE FOREIGN TABLE ossexample 
    (date text, time text, volume int) 
     SERVER ossserver 
     OPTIONS ( filepath 'osstest/example.csv', delimiter ',' , 
        format 'csv', encoding 'utf8', PARSE_ERRORS '100');

这样即创建了映射到OSS对象的表,通过对ossexample的读写即是对OSS的读写。在数据写入”local_tbl”中后,执行以下SQL:

# 数据写入OSS
insert into ossexample  
  select date, time, volume)  from local_tbl  where date > '2017-09-20';  

表”local_tbl”中满足过滤条件的数据,即会写入OSS对应的对象”osstest/example.csv”中。

在HybridDB for PostgreSQL也用与此类似的方式读写OSS。整个过程,用户看到的只是一条条SQL。如下:

# 创建外部表,用于导出数据到OSS
create WRITABLE external table ossexample_exp 
        (date text, time text, volume int) 
        location('oss://oss-cn-hangzhou.aliyuncs.com
        prefix=osstest/exp/outfromhdb id=XXX
        key=XXX bucket=testbucket') FORMAT 'csv'
        DISTRIBUTED BY (date);

# 创建堆表,数据就装载到这张表中
create table example
        (date text, time text, volume int)
         DISTRIBUTED BY (date);

# 数据并行的从 ossexample 装载到 example 中
insert into example select * from ossexample;

该INSERT语句的执行,即会将”osstest/exp/outfromhdb” 文件中的数据,并行写入到表”example”中。其原理如下:

HybridDB读取OSS.jpeg

HybridDB 是分布式数据库,一个HybridDB for PostgreSQL集群中,有一个Master和多个Segment,Segment的个数可以横向扩充。Segment负责存储、分析数据,Master则是主入口接受查询请求并分发。

通过每个Segment并行从OSS上读取数据,整个集群可以达到相当高的吞吐能力,且这个能力随Segment个数而线性增加。

方案优势

上面的方案初看起来并不复杂,却解决了下面几个问题:

1.性能

融合了PostgreSQL超强的并发写入性能与HybridDB卓越的分析性能。

单个RDS PostgreSQL甚至可以支撑到百万级的写入; 而写入PostgreSQL后批量加载到HybridDB,使得PostgreSQL与HybridDB无缝衔接,利用MPP卓越的分析性能做到实时的毫秒级查询。

2.数据的搬运与清洗

在传统的分析领域,数据的搬运往往是比较重、且性能较差的一环,导致TP和AP距离较远,只能采用截然不同的方式和节奏。而如果是异构数据库的搬运,则痛苦指数再上台阶。

如果这些,都可以通过SQL来操作,数据的清洗和搬运最终都只是SQL的定义与执行,岂不美哉?

在上图中,RDS PostgreSQL 和 HybridDB for PostgreSQL都有直接读写OSS的能力,可以很容易地的串联起来。假以合理的调度和封装,可以以较低的成本实现原本需要很多工作量的功能。

3.冷热数据的统一

而借操作离线存储的能力,可以将冷数据放在OSS,热数据放在PostgreSQL或者HybridDB for PostgreSQL,可以通过SQL以相同的处理方式实现对冷热数据的统一处理。

4.动态调整资源

云生态的好处之一就是动态与弹性。RDS PostgreSQL的资源可以随时动态调整,而不影响任何的可用性,相当于给飞机在空中加油;而对HybridDB的扩容与缩容,则是秒级切换即可完成。OSS本身的弹性,也允许客户放多少的数据都可以。

因此,带来了如下几点优势:

  1. 相比于传统的数据分析方案,以SQL为统一的方式进行数据的管理,减少异构;
  2. 资源动态调度,降低成本
  3. 冷热数据界限模糊,直接互相访问
  4. TP、AP一体化
  5. RDS PostgreSQL的个数没有限制;HybridDB集群的数量没有限制

阿里云云数据库PostgreSQL

阿里云云数据库 PostgreSQL,基于号称“Most Advanced”的开源关系型数据库。在StackOverflow 2017开发者调查中,PostgreSQL可以说是“年度统计中开发者最爱和最想要的关系型数据库”

image.png

image.png

PostgreSQL的优势有以下几点:

  • 稳定

    PostgreSQL的代码质量是被很多人认可的,经常会有人笑称PG的开发者都是处女座。基本上,PG的一个大版本发布,经过三两个小版本就可以上生产,这是值得为人称道的一个地方。从PostgreSQL漂亮的commit log就可见一斑。

    而得益于PostgreSQL的多进程架构,一个连接的异常并不影响主进程和其他连接,从而带来不错的稳定性。

  • 性能

    我们内部有些性能上的数据,TPCC的性能测试显示PostgreSQL的性能与商业数据库基本在同一个层面上。

  • 丰富

    PostgreSQL的丰富性是最值得诉说的地方。因为太丰富了,以至于不知道该如何突出重点。这里只列举几个认为比较有意思的几点(查询、类型、功能):

    • 查询的丰富

      且不说HASH\Merge\NestLoop JOIN,还有递归、树形(connect by)、窗口、rollup\cube\grouping sets、物化视图、SQL标准等,还有各种全文检索、规则表达式、模糊查询、相似度等。在这些之外,最重要的是PostgreSQL强大的基于成本的优化器,结合并行执行(并行扫瞄、并行JOIN等)和多种成本因子,带来各种各样丰富灵活高效的查询支持。

    • 类型的丰富

      如高精度numeric, 浮点, 自增序列,货币,字节流,时间,日期,时间戳,布尔, 枚举,平面几何,立体几何,多维几何,地球,PostGIS,网络,比特流,全 文检索,UUID,XML,JSON,数组,复合类型,域类型,范围,树类型,化 学类型,基因序列,FDW, 大对象, 图像等。

      [PS: 这里的数组,可以让用户像操作JAVA中的数组一样操作数据库中的数据,如 item[0][1]即表示二维数组中的一个元素,而item可以作为表的一个字段。]

      或者,如果以上不够满足,你可以自定义自己的类型(create type),并且可以针对这些类型进行运算符重载,比如实现IP类型的加减乘除(其操作定义依赖于具体实现,意思是:你想让IP的加法是什么样子就是什么样子)。

    另外还有各种索引的类型,如btree, hash, gist, sp-gist, gin, brin , bloom , rum 索引等。你甚至可以为自己定义的类型定制特定的索引和索引扫瞄。

    • 功能的丰富

      PostgreSQL有一个无与伦比的特性——插件。其利用内核代码中的Hook,可以让你在不修改数据库内核代码的情况下,自主添加任意功能,如PostGIS、JSON、基因等,都是在插件中做了很多的自定义而又不影响任何内核代码从而满足丰富多样的需求。而PostgreSQL的插件,不计其数。

      FDW机制更让你可以在同一个PostgreSQL中像操作本地表一样访问其他数据源,如Hadoop、MySQL、Oracle、Mongo等,且不会占用PG的过多资源。比如我们团队开发的OSS_FDW就用于实现对OSS的读写。

至于其他的,举个简单的例子,PostgreSQL的DDL(如加减字段)是可以在事务中完成的 [PS: PostgreSQL是Catalog-Driven的,DDL的修改基本可以理解为一条记录的修改]。这一点,相信做业务的同学会有体会。

而在开源版本的基础上,阿里云云数据库PostgreSQL增加了HA、无缝扩缩容、自动备份、恢复与无感知切换、离线存储透明访问、诊断与优化等诸多功能,解除使用上的后顾之忧。更多的建议访问阿里云官网产品页(见文下参考)。

阿里云HybridDB for PostgreSQL

HybridDB for PostgreSQL是MPP架构的分布式分析型数据库,基于开源Greenplum,在多表关联、复杂查询、实时统计、圈人等诸多方面性能卓越。在此基础上,阿里云HybridDB for PostgreSQL提供JSON、GIS、HLL估值、备份恢复、异常自动化修复等多种独特的功能特性;并在METASCAN等方面做了诸多性能优化,相比开源版本有质的提升。

阿里云HybridDB for PostgreSQL有以下特点:

  • 实时分析

    支持SQL语法进行分布式GIS地理信息数据类型实时分析,协助物联网、互联网实现LBS位置服务统计;支持SQL语法进行分布式JSON、XML、模糊字符串等数据实时分析,助金融、政企行业实现报文数据处理及模糊文本统计。

  • 稳定可靠

    支持分布式ACID数据一致性,实现跨节点事务一致,所有数据双节点同步冗余,SLA保障99.9%可用性;分布式部署,计算单元、服务器、机柜三重防护,提高重要数据基础设施保障。

  • 简单易用

    丰富的OLAP SQL语法及函数支持,众多Oracle函数支持,业界流行的BI软件可直接联机使用;可与云数据库RDS(PostgreSQL/PPAS)实现数据通讯,实现OLTP+OLAP(HTAP)混合事务分析解决方案。

    支持分布式的SQL OLAP统计及窗口函数,支持分布式PL/pgSQL存储过程、触发器,实现数据库端分布式计算过程开发。

    符合国际OpenGIS标准的地理数据混合分析,通过单条SQL即可从海量数据中进行地理信息的分析,如:人流量、面积统计、行踪等分析。

  • 性能卓越

    支持行列混合存储,列存性能在OLAP分析时相比行存储可达100倍性能提升;支持高性能OSS并行数据导入,避免单通道导入的性能瓶颈。

    基于分布式大规模并行处理,随计算单元的添加线性扩展存储及计算能力,充分发挥每个计算单元的OLAP计算效能。

  • 灵活扩展

    按需进行计算单元,CPU、内存、存储空间的等比扩展,OLAP性能平滑上升致数百TB;支持透明的OSS数据操作,非在线分析的冷数据可灵活转存到OSS对象存储,数据存储容量无限扩展。

    通过MySQL数据库可以通过mysql2pgsql进行高性能数据导入,同时业界流行的ETL工具均可支持以HybridDB为目标的ETL数据导入。

    可将存储于OSS中的格式化文件作为数据源,通过外部表模式进行实时操作,使用标准SQL语法实现数据查询。

    支持数据从PostgreSQL/PPAS透明流入,持续增量无需编程处理,简化维护工作,数据入库后可再进行高性能内部数据建模及数据清洗。

  • 安全

    IP白名单配置,最多支持配置1000个允许连接RDS实例的服务器IP地址,从访问源进行直接的风险控制。

    DDOS防护, 在网络入口实时监测,当发现超大流量攻击时,对源IP进行清洗,清洗无效情况下可以直接拉进黑洞。

总结

利用阿里云的云生态,RDS PostgreSQL、HybridDB for PostgreSQL等一系列云服务,帮助企业打造智能的企业数据BI平台,HybridDB for PostgreSQL也企业大数据实时分析运算和存储的核心引擎。实现企业在云端从在线业务、到数据实时分析的业务数据闭环。

参考

MySQL · 引擎特性 · TokuDB hot-index机制

$
0
0

所谓hot-index就是指在构建索引的过程中不会阻塞查询数据,也不会阻塞修改数据(insert/update/delete)。在TokuDB的实现中只有使用“create index“方式创建索引的情况下才能使用hot-index;如果使用“alter table add index”是会阻塞更新操作的。

TokuDB handler的ha_tokudb::store_lock判断是create index方式创建索引并且只创建一个索引会把lock_type改成TL_WRITE_ALLOW_WRITE,这是一个特殊的锁类型,意思是在执行写操作的过程允许其他的写操作。

TokuDB提供了session变量tokudb_create_index_online,在线开启或者关闭hot-index功能。

THR_LOCK_DATA* *ha_tokudb::store_lock(
    THD* thd,
    THR_LOCK_DATA** to,
    enum thr_lock_type lock_type) {

    if (lock_type != TL_IGNORE && lock.type == TL_UNLOCK) {
        enum_sql_command sql_command = (enum_sql_command) thd_sql_command(thd);
        if (!thd->in_lock_tables) {
            if (sql_command == SQLCOM_CREATE_INDEX &&
                tokudb::sysvars::create_index_online(thd)) {
                // hot indexing
                share->_num_DBs_lock.lock_read();
                if (share->num_DBs == (table->s->keys + tokudb_test(hidden_primary_key))) {
                    lock_type = TL_WRITE_ALLOW_WRITE;
                }
                share->_num_DBs_lock.unlock();
            } else if ((lock_type >= TL_WRITE_CONCURRENT_INSERT &&
                        lock_type <= TL_WRITE) &&
                        sql_command != SQLCOM_TRUNCATE &&
                        !thd_tablespace_op(thd)) {
                // allow concurrent writes
                lock_type = TL_WRITE_ALLOW_WRITE;
            } else if (sql_command == SQLCOM_OPTIMIZE &&
                       lock_type == TL_READ_NO_INSERT) {
                // hot optimize table
                lock_type = TL_READ;
            }
        }
        lock.type = lock_type;
    }
}

代码逻辑如下图所示:

图片.png

ha_tokudb::tokudb_add_index是负责创建索引的方法。这个函数首先会判断如下条件:如果同时满足以下三个条件就会走到hot-index的逻辑,否则是传统的创建索引过程。

  • 锁类型是TL_WRITE_ALLOW_WRITE
  • 只创建一个索引
  • 不是unique索引
int ha_tokudb::tokudb_add_index(
    TABLE* table_arg,
    KEY* key_info,
    uint num_of_keys,
    DB_TXN* txn,
    bool* inc_num_DBs,
    bool* modified_DBs) {

    bool use_hot_index = (lock.type == TL_WRITE_ALLOW_WRITE);

    creating_hot_index =
        use_hot_index && num_of_keys == 1 &&
        (key_info[0].flags & HA_NOSAME) == 0;

    if (use_hot_index && (share->num_DBs > curr_num_DBs)) {
        //
        // already have hot index in progress, get out
        //
        error = HA_ERR_INTERNAL_ERROR;
        goto cleanup;
    }

TokuDB目前只支持一个hot-index,也就是说同时只允许有一个hot-index在进行。如果hot-index过程中有新的创建索引操作会走传统的建索引逻辑。

传统的创建索引的方式是利用loader机制实现的,关于loader部分(点击这里跳转到原文)里面有比较详细的描述。

hot-index设计思路

对于是hot-index方式,首先通过调用db_env->create_index接口创建一个hot-index的handle,然后通过这个handle调用build方法构建索引数据,最后是调用close方法关闭handle。

大致过程如下:

图片.png

int ha_tokudb::tokudb_add_index(
    TABLE* table_arg,
    KEY* key_info,
    uint num_of_keys,
    DB_TXN* txn,
    bool* inc_num_DBs,
    bool* modified_DBs) {

   // 省略前面部分代码
    if (creating_hot_index) {
        share->num_DBs++;
        *inc_num_DBs = true;
        error = db_env->create_indexer(
            db_env,
            txn,
            &indexer,
            share->file,
            num_of_keys,
            &share->key_file[curr_num_DBs],
            mult_db_flags,
            indexer_flags);
        if (error) {
            goto cleanup;
        }

        error = indexer->set_poll_function(
            indexer, ha_tokudb::tokudb_add_index_poll, &lc);
        if (error) {
            goto cleanup;
        }

        error = indexer->set_error_callback(
            indexer, ha_tokudb::loader_add_index_err, &lc);
        if (error) {
            goto cleanup;
        }

        share->_num_DBs_lock.unlock();
        rw_lock_taken = false;

#ifdef HA_TOKUDB_HAS_THD_PROGRESS
        // initialize a one phase progress report.
        // incremental reports are done in the indexer's callback function.
        thd_progress_init(thd, 1);
#endif

        error = indexer->build(indexer);

        if (error) {
            goto cleanup;
        }

        share->_num_DBs_lock.lock_write();
        error = indexer->close(indexer);
        share->_num_DBs_lock.unlock();
        if (error) {
            goto cleanup;
        }
        indexer = NULL;
    }

build设计思想是通过遍历pk构造二级索引。在pk上创建一个le cursor,这个cursor特别之处是读取的是MVCC结构(即leafentry)而不是数据。Le cursor遍历的方向是从正无穷(最大的key值)向前访问,一直到负无穷(最小的key值)。通过Le cursor的key和value(从MVCC中得到的)构造二级索引的key;通过pk MVCC中的事务信息,构建二级索引的MVCC。

图片.png

创建indexer

indexer数据结构介绍

db_env->create_indexer其实就是toku_indexer_create_indexer,是在toku_env_create阶段设置的。 在create_indexer阶段,最主要工作就是初始化DB_INDEXER数据结构。

DB_INDEXER其实是一个接口类主要定义了build,close,abort等callback函数,其主体成员变量定义在struct __toku_indexer_internal里面。 DB_INDEXER定义如下:

typedef struct __toku_indexer DB_INDEXER;
struct __toku_indexer_internal;
struct __toku_indexer {
  struct __toku_indexer_internal *i;
  int (*set_error_callback)(DB_INDEXER *indexer, void (*error_cb)(DB *db, int i, int err, DBT *key, DBT *val, void *error_extra), void *error_extra); /* set the error callback */
  int (*set_poll_function)(DB_INDEXER *indexer, int (*poll_func)(void *extra, float progress), void *poll_extra);             /* set the polling function */
  int (*build)(DB_INDEXER *indexer);  /* build the indexes */
  int (*close)(DB_INDEXER *indexer);  /* finish indexing, free memory */
  int (*abort)(DB_INDEXER *indexer);  /* abort  indexing, free memory */
};

__toku_indexer_internal定义如下所示

图片.png

struct __toku_indexer_internal {
    DB_ENV *env;
    DB_TXN *txn;
    toku_mutex_t indexer_lock;
    toku_mutex_t indexer_estimate_lock;
    DBT position_estimate;
    DB *src_db;
    int N;
    DB **dest_dbs; /* [N] */
    uint32_t indexer_flags;
    void (*error_callback)(DB *db, int i, int err, DBT *key, DBT *val, void *error_extra);
    void *error_extra;
    int  (*poll_func)(void *poll_extra, float progress);
    void *poll_extra;
    uint64_t estimated_rows; // current estimate of table size
    uint64_t loop_mod;       // how often to call poll_func
    LE_CURSOR lec;
    FILENUM  *fnums; /* [N] */
    FILENUMS filenums;

    // undo state
    struct indexer_commit_keys commit_keys; // set of keys to commit
    DBT_ARRAY *hot_keys;
    DBT_ARRAY *hot_vals;

    // test functions
    int (*undo_do)(DB_INDEXER *indexer, DB *hotdb, DBT* key, ULEHANDLE ule);
    TOKUTXN_STATE (*test_xid_state)(DB_INDEXER *indexer, TXNID xid);
    void (*test_lock_key)(DB_INDEXER *indexer, TXNID xid, DB *hotdb, DBT *key);
    int (*test_delete_provisional)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, XIDS xids);
    int (*test_delete_committed)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, XIDS xids);
    int (*test_insert_provisional)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, DBT *hotval, XIDS xids);
    int (*test_insert_committed)(DB_INDEXER *indexer, DB *hotdb, DBT *hotkey, DBT *hotval, XIDS xids);
    int (*test_commit_any)(DB_INDEXER *indexer, DB *db, DBT *key, XIDS xids);

    // test flags
    int test_only_flags;
};

db_env->create_index函数主要是初始化DB_INDEXER数据结构,这部分代码比较简单,请大家自行分析。

有一点需要提下,db_env->create_index调用toku_loader_create_loader创建一个dummy的索引。当build过程出错时,会放弃之前的所有操作,把索引重定向到那个dummy索引。这是利用loader redirect FT handle的功能,创建loader时指定LOADER_DISALLOW_PUTS标记。

构建indexer

构建indexer的函数是DB_INDEXER->build,其实调用的是build_index函数。

build_index主体是一个循环,每次去pk上读取一个key。前面提到过,访问pk是通过le cursor,每次向前访问,读取key和MVCC信息,在cursor callback把相应信息填到ule_prov_info数据结构里。le cursor的callback是le_cursor_callback,通过txn_manager得到第一个uncommitted txn信息,然后在通过那个txn的txn_child_manager得到其他的uncommitted txn信息。

在处理每个pk的key时,是受indexer->i->indexer_lock互斥锁保护的,保证build过程跟用户的dml语句互斥。build的过程还获取了multi_operation_lock读写锁的读锁。在处理当前pk值,是不允许dml和checkpoint的。对于每个pk的<key,mvcc>二元组,调用indexer_undo_do函数来构建二级索引的key和mvcc信息。下面函数中hot_keys和hot_vals是生成二级索引key和val的buffer。

struct ule_prov_info {
    // these are pointers to the allocated leafentry and ule needed to calculate
    // provisional info. we only borrow them - whoever created the provisional info
    // is responsible for cleaning up the leafentry and ule when done.
    LEAFENTRY le;    //packed MVCC info
    ULEHANDLE ule;   //unpacked MVCC info
    void* key;        // key
    uint32_t keylen;  // key length

    // provisional txn info for the ule
    uint32_t num_provisional;  // uncommitted txn number
    uint32_t num_committed;    // committed txn number
    TXNID *prov_ids;           // each txnid for uncommitted txn
    TOKUTXN *prov_txns;        // each txn for uncommited txn
    TOKUTXN_STATE *prov_states; // each txn state for uncommitted txn
};

static int
build_index(DB_INDEXER *indexer) {
    int result = 0;

    bool done = false;
    for (uint64_t loop_count = 0; !done; loop_count++) {

        toku_indexer_lock(indexer);
        // grab the multi operation lock because we will be injecting messages
        // grab it here because we must hold it before
        // trying to pin any live transactions, as discovered by #5775
        toku_multi_operation_client_lock();

        // grab the next leaf entry and get its provisional info. we'll
        // need the provisional info for the undo-do algorithm, and we get
        // it here so it can be read atomically with respect to txn commit
        // and abort. the atomicity comes from the root-to-leaf path pinned
        // by the query and in the getf callback function
        //
        // this allocates space for the prov info, so we have to destroy it
        // when we're done.
        struct ule_prov_info prov_info;
        memset(&prov_info, 0, sizeof(prov_info));
        result = get_next_ule_with_prov_info(indexer, &prov_info);

        if (result != 0) {
            invariant(prov_info.ule == NULL);
            done = true;
            if (result == DB_NOTFOUND) {
                result = 0;  // all done, normal way to exit loop successfully
            }
        }
        else {
            invariant(prov_info.le);
            invariant(prov_info.ule);
            for (int which_db = 0; (which_db < indexer->i->N) && (result == 0); which_db++) {
                DB *db = indexer->i->dest_dbs[which_db];
                DBT_ARRAY *hot_keys = &indexer->i->hot_keys[which_db];
                DBT_ARRAY *hot_vals = &indexer->i->hot_vals[which_db];
                result = indexer_undo_do(indexer, db, &prov_info, hot_keys, hot_vals);
                if ((result != 0) && (indexer->i->error_callback != NULL)) {
                    // grab the key and call the error callback
                    DBT key; toku_init_dbt_flags(&key, DB_DBT_REALLOC);
                    toku_dbt_set(prov_info.keylen, prov_info.key, &key, NULL);
                    indexer->i->error_callback(db, which_db, result, &key, NULL, indexer->i->error_extra);
                    toku_destroy_dbt(&key);
                }
            }
            // the leafentry and ule are not owned by the prov_info,
            // and are still our responsibility to free
            toku_free(prov_info.le);
            toku_free(prov_info.key);
            toku_ule_free(prov_info.ule);
        }

        toku_multi_operation_client_unlock();
        toku_indexer_unlock(indexer);
        ule_prov_info_destroy(&prov_info);

        if (result == 0) {
            result = maybe_call_poll_func(indexer, loop_count);
        }
        if (result != 0) {
            done = true;
        }
    }
}

写了这么多都是framework,汗:(

indexer_undo_do函数才是build灵魂,每次调用生成二级索引的key和MVCC信息。传入参数是ule_prov_info,封装了pk的key和mvcc信息。

indexer_undo_do实现很直接,首先调用 indexer_undo_do_committed处理已提交事务对二级索引的修改,这些修改在pk上是提交的,那么在二级索引上面也一定是提交的。反复修改同一个pk会导致产生多个二级索引的key值。在pk上的体现是新值override老值;而在二级索引上就是要删老值,加新值。这也就是undo_do的意思啦。

处理committed事务时,每次处理完成都要记住新添加的二级索引的key值。最后对每个key发一个FT_COMMIT_ANY消息,整理MVCC结构,DB_INDEXER->commit_keys就是记录已提交二级索引key的,是一个数组。

int
indexer_undo_do(DB_INDEXER *indexer, DB *hotdb, struct ule_prov_info *prov_info, DBT_ARRAY *hot_keys, DBT_ARRAY *hot_vals) {
    int result = indexer_undo_do_committed(indexer, hotdb, prov_info, hot_keys, hot_vals);
    if (result == 0) {
        result = indexer_undo_do_provisional(indexer, hotdb, prov_info, hot_keys, hot_vals);
    }
    if (indexer->i->test_only_flags == INDEXER_TEST_ONLY_ERROR_CALLBACK)  {
        result = EINVAL;
    }

    return result;
}

indexer_undo_do_committed函数相对简单,请大家自行分析。

下面一起看一下indexer_undo_do_provisional函数。如果num_provisional等于0,没有正在进行中的事务,直接返回。

然后依次查看每个provisional事务,uxr表示当前provisional事务的信息,包括value,txnid和delete标记。this_xid表示当前事务的txnid;this_xid_state表示当前事务的状态。

如果当前事务状态是TOKUTXN_ABORTING,啥也不用干,省得以后在root txn commit时还要再去做rollback。

条件xrindex == num_committed表示当前事务的root txn,一定把它加到xids里面;否则,意味着是子事务,只有当它处于TOKUTXN_LIVE状态时加到xids里面。xids数组是为了往FT发msg用的,表示msg所处txn上下文。

对于provisional事务,也有undo和do阶段。针对mvcc里面的nested txn,undo阶段删除old image对应的二级索引key,do阶段添加new image对应的二级索引key。这部分跟indexer_undo_do_committed类似。

只不过indexer_undo_do_provisional需要考虑最外层provisional事务(当前alive事务的root txn)的状态。

如果是TOKUTXN_LIVE或者TOKUTXN_PREPARING表名root txn正在进行中,模拟用户写索引的行为,直接调用toku_ft_maybe_delete(删除old key)或者toku_ft_maybe_insert(添加new key),这个过程是需要记undo log和redo log的,因为pk上这个事务正在进行中。

如果最外层provisional事务(当前alive事务的root txn)的状态是TOKUTXN_COMMITTING或者TOKUTXN_RETIRED表示pk上这个事务准备提交或者已经提交,直接删除old key或者添加new key,不需要记undo log和redo log,因为pk预期是提交的。

对应每个pk上面是提交的key,也需要记录下来,在结束前对每个key发FT_COMMIT_ANY消息整理MVCC结构。

release_txns函数unpin每个活跃的provisional事务,pin的过程是在toku_txn_pin_live_txn_unlocked做的;pin的目的是防止txn commit或者abort。

static int
indexer_undo_do_provisional(DB_INDEXER *indexer, DB *hotdb, struct ule_prov_info *prov_info, DBT_ARRAY *hot_keys, DBT_ARRAY *hot_vals) {
    int result = 0;
    indexer_commit_keys_set_empty(&indexer->i->commit_keys);
    ULEHANDLE ule = prov_info->ule;

    // init the xids to the root xid
    XIDS xids = toku_xids_get_root_xids();

    uint32_t num_provisional = prov_info->num_provisional;
    uint32_t num_committed = prov_info->num_committed;
    TXNID *prov_ids = prov_info->prov_ids;
    TOKUTXN *prov_txns = prov_info->prov_txns;
    TOKUTXN_STATE *prov_states = prov_info->prov_states;

    // nothing to do if there's nothing provisional
    if (num_provisional == 0) {
        goto exit;
    }

    TXNID outermost_xid_state;
    outermost_xid_state = prov_states[0];

    // scan the provisional stack from the outermost to the innermost transaction record
    TOKUTXN curr_txn;
    curr_txn = NULL;
    for (uint64_t xrindex = num_committed; xrindex < num_committed + num_provisional; xrindex++) {

        // get the ith transaction record
        UXRHANDLE uxr = ule_get_uxr(ule, xrindex);

        TXNID this_xid = uxr_get_txnid(uxr);
        TOKUTXN_STATE this_xid_state = prov_states[xrindex - num_committed];

        if (this_xid_state == TOKUTXN_ABORTING) {
            break;         // nothing to do once we reach a transaction that is aborting
        }

        if (xrindex == num_committed) { // if this is the outermost xr
            result = indexer_set_xid(indexer, this_xid, &xids);    // always add the outermost xid to the XIDS list
            curr_txn = prov_txns[xrindex - num_committed];
        } else {
            switch (this_xid_state) {
            case TOKUTXN_LIVE:
                result = indexer_append_xid(indexer, this_xid, &xids); // append a live xid to the XIDS list
                curr_txn = prov_txns[xrindex - num_committed];
                if (!indexer->i->test_xid_state) {
                    assert(curr_txn);
                }
                break;
            case TOKUTXN_PREPARING:
                assert(0); // not allowed
            case TOKUTXN_COMMITTING:
            case TOKUTXN_ABORTING:
            case TOKUTXN_RETIRED:
                break; // nothing to do
            }
        }
        if (result != 0)
            break;

        if (outermost_xid_state != TOKUTXN_LIVE && xrindex > num_committed) {
            // If the outermost is not live, then the inner state must be retired.  That's the way that the txn API works.
            assert(this_xid_state == TOKUTXN_RETIRED);
        }

        if (uxr_is_placeholder(uxr)) {
            continue;         // skip placeholders
        }
        // undo
        uint64_t prev_xrindex;
        bool prev_xrindex_found = indexer_find_prev_xr(indexer, ule, xrindex, &prev_xrindex);
        if (prev_xrindex_found) {
            UXRHANDLE prevuxr = ule_get_uxr(ule, prev_xrindex);
            if (uxr_is_delete(prevuxr)) {
                ; // do nothing
            } else if (uxr_is_insert(prevuxr)) {
                // generate the hot delete key
                result = indexer_generate_hot_keys_vals(indexer, hotdb, prov_info, prevuxr, hot_keys, NULL);
                if (result == 0) {
                    paranoid_invariant(hot_keys->size <= hot_keys->capacity);
                    for (uint32_t i = 0; i < hot_keys->size; i++) {
                        DBT *hotkey = &hot_keys->dbts[i];

                        // send the delete message
                        switch (outermost_xid_state) {
                        case TOKUTXN_LIVE:
                        case TOKUTXN_PREPARING:
                            invariant(this_xid_state != TOKUTXN_ABORTING);
                            invariant(!curr_txn || toku_txn_get_state(curr_txn) == TOKUTXN_LIVE || toku_txn_get_state(curr_txn) == TOKUTXN_PREPARING);
                            result = indexer_ft_delete_provisional(indexer, hotdb, hotkey, xids, curr_txn);
                            if (result == 0) {
                                indexer_lock_key(indexer, hotdb, hotkey, prov_ids[0], curr_txn);
                            }
                            break;
                        case TOKUTXN_COMMITTING:
                        case TOKUTXN_RETIRED:
                            result = indexer_ft_delete_committed(indexer, hotdb, hotkey, xids);
                            if (result == 0)
                                indexer_commit_keys_add(&indexer->i->commit_keys, hotkey->size, hotkey->data);
                            break;
                        case TOKUTXN_ABORTING: // can not happen since we stop processing the leaf entry if the outer most xr is aborting
                            assert(0);
                        }
                    }
                }
            } else
                assert(0);
        }
        if (result != 0)
            break;

        // do
        if (uxr_is_delete(uxr)) {
            ; // do nothing
        } else if (uxr_is_insert(uxr)) {
            // generate the hot insert key and val
            result = indexer_generate_hot_keys_vals(indexer, hotdb, prov_info, uxr, hot_keys, hot_vals);
            if (result == 0) {
                paranoid_invariant(hot_keys->size == hot_vals->size);
                paranoid_invariant(hot_keys->size <= hot_keys->capacity);
                paranoid_invariant(hot_vals->size <= hot_vals->capacity);
                for (uint32_t i = 0; i < hot_keys->size; i++) {
                    DBT *hotkey = &hot_keys->dbts[i];
                    DBT *hotval = &hot_vals->dbts[i];

                    // send the insert message
                    switch (outermost_xid_state) {
                    case TOKUTXN_LIVE:
                    case TOKUTXN_PREPARING:
                        assert(this_xid_state != TOKUTXN_ABORTING);
                        invariant(!curr_txn || toku_txn_get_state(curr_txn) == TOKUTXN_LIVE || toku_txn_get_state(curr_txn) == TOKUTXN_PREPARING);
                        result = indexer_ft_insert_provisional(indexer, hotdb, hotkey, hotval, xids, curr_txn);
                        if (result == 0) {
                            indexer_lock_key(indexer, hotdb, hotkey, prov_ids[0], prov_txns[0]);
                        }
                        break;
                    case TOKUTXN_COMMITTING:
                    case TOKUTXN_RETIRED:
                        result = indexer_ft_insert_committed(indexer, hotdb, hotkey, hotval, xids);
                        // no need to do this because we do implicit commits on inserts
                        if (0 && result == 0)
                            indexer_commit_keys_add(&indexer->i->commit_keys, hotkey->size, hotkey->data);
                        break;
                    case TOKUTXN_ABORTING: // can not happen since we stop processing the leaf entry if the outer most xr is aborting
                        assert(0);
                    }
                }
            }
        } else
            assert(0);

        if (result != 0)
            break;
    }

    // send commits if the outermost provisional transaction is committed
    for (int i = 0; result == 0 && i < indexer_commit_keys_valid(&indexer->i->commit_keys); i++) {
        result = indexer_ft_commit(indexer, hotdb, &indexer->i->commit_keys.keys[i], xids);
    }

    // be careful with this in the future. Right now, only exit path
    // is BEFORE we call fill_prov_info, so this happens before exit
    // If in the future we add a way to exit after fill_prov_info,
    // then this will need to be handled below exit
    release_txns(ule, prov_states, prov_txns, indexer);
exit:
    toku_xids_destroy(&xids);
    return result;
}

关闭indexer

这部分就是关闭handle,释放内存。由于篇幅有限,本文不深入讨论。

与dml互斥

每个更新操作,包括insert,update和delete都要比较待处理的二级索引key是否落在已经build的部分。如果是,其处理方式跟通常的一样,直接调用db接口;否则留给hot-index来处理。

判断key是否落在已build好的部分是通过toku_indexer_should_insert_key函数比较le cursor正在处理的key和pk的key来实现的。为了避免访问le cursor的竞态,每次比较都是在indexer->i->indexer_lock保护下进行。直觉告诉我们,这个操作会影响性能,并发写可能会在indexer->i->indexer_lock上排队。

hot-index维护了le cursor大致位置indexer->i->position_estimate,这个位置是延迟更新的。每次访问le cursor比较后更新这个位置。那么,比它大的key一定落在build好的部分的。

与indexer->i->position_estimate比较的过程是不需要获取indexer->i->indexer_lock的,利用它可以做个快算判断,减少indexer->i->indexer_lock争抢。

其实,indexer->i->position_estimate更新是受indexer->i->indexer_estimate_lock保护的,这也可以算是锁拆分优化。

需要注意的是indexer->i->position_estimate和le cursor正在处理的key(更精确)都是指pk上的位置。

// a shortcut call
//
// a cheap(er) call to see if a key must be inserted
// into the DB. If true, then we know we have to insert.
// If false, then we don't know, and have to check again
// after grabbing the indexer lock
bool
toku_indexer_may_insert(DB_INDEXER* indexer, const DBT* key) {
    bool may_insert = false;
    toku_mutex_lock(&indexer->i->indexer_estimate_lock);

    // if we have no position estimate, we can't tell, so return false
    if (indexer->i->position_estimate.data == nullptr) {
        may_insert = false;
    } else {
        DB *db = indexer->i->src_db;
        const toku::comparator &cmp = toku_ft_get_comparator(db->i->ft_handle);
        int c = cmp(&indexer->i->position_estimate, key);

        // if key > position_estimate, then we know the indexer cursor
        // is past key, and we can safely say that associated values of
        // key must be inserted into the indexer's db
        may_insert = c < 0;
    }

    toku_mutex_unlock(&indexer->i->indexer_estimate_lock);
    return may_insert;
}

到这里,hot-index部分就介绍完了。代码看着复杂,但比起loader来要简单不少。

MySQL · 最佳实践 · 分区表基本类型

$
0
0

MySQL分区表概述

随着MySQL越来越流行,Mysql里面的保存的数据也越来越大。在日常的工作中,我们经常遇到一张表里面保存了上亿甚至过十亿的记录。这些表里面保存了大量的历史记录。 对于这些历史数据的清理是一个非常头疼事情,由于所有的数据都一个普通的表里。所以只能是启用一个或多个带where条件的delete语句去删除(一般where条件是时间)。 这对数据库的造成了很大压力。即使我们把这些删除了,但底层的数据文件并没有变小。面对这类问题,最有效的方法就是在使用分区表。最常见的分区方法就是按照时间进行分区。 分区一个最大的优点就是可以非常高效的进行历史数据的清理。

分区类型

目前MySQL支持范围分区(RANGE),列表分区(LIST),哈希分区(HASH)以及KEY分区四种。下面我们逐一介绍每种分区:

RANGE分区

基于属于一个给定连续区间的列值,把多行分配给分区。最常见的是基于时间字段. 基于分区的列最好是整型,如果日期型的可以使用函数转换为整型。本例中使用to_days函数

CREATE TABLE my_range_datetime(
    id INT,
    hiredate DATETIME
) 
PARTITION BY RANGE (TO_DAYS(hiredate) ) (
    PARTITION p1 VALUES LESS THAN ( TO_DAYS('20171202') ),
    PARTITION p2 VALUES LESS THAN ( TO_DAYS('20171203') ),
    PARTITION p3 VALUES LESS THAN ( TO_DAYS('20171204') ),
    PARTITION p4 VALUES LESS THAN ( TO_DAYS('20171205') ),
    PARTITION p5 VALUES LESS THAN ( TO_DAYS('20171206') ),
    PARTITION p6 VALUES LESS THAN ( TO_DAYS('20171207') ),
    PARTITION p7 VALUES LESS THAN ( TO_DAYS('20171208') ),
    PARTITION p8 VALUES LESS THAN ( TO_DAYS('20171209') ),
    PARTITION p9 VALUES LESS THAN ( TO_DAYS('20171210') ),
    PARTITION p10 VALUES LESS THAN ( TO_DAYS('20171211') ),
    PARTITION p11 VALUES LESS THAN (MAXVALUE) 
);

p11是一个默认分区,所有大于20171211的记录都会在这个分区。MAXVALUE是一个无穷大的值。p11是一个可选分区。如果在定义表的没有指定的这个分区,当我们插入大于20171211的数据的时候,会收到一个错误。

我们在执行查询的时候,必须带上分区字段。这样可以使用分区剪裁功能

mysql> insert into my_range_datetime select * from test;                                                                    
Query OK, 1000000 rows affected (8.15 sec)
Records: 1000000  Duplicates: 0  Warnings: 0

mysql> explain partitions select * from my_range_datetime where hiredate >= '20171207124503' and hiredate<='20171210111230'; 
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table             | partitions   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | my_range_datetime | p7,p8,p9,p10 | ALL  | NULL          | NULL | NULL    | NULL | 400061 | Using where |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
1 row in set (0.03 sec)

注意执行计划中的partitions的内容,只查询了p7,p8,p9,p10三个分区,由此来看,使用to_days函数确实可以实现分区裁剪。

上面是基于datetime的,如果是timestamp类型,我们遇到上面问题呢?

事实上,MySQL提供了一种基于UNIX_TIMESTAMP函数的RANGE分区方案,而且,只能使用UNIX_TIMESTAMP函数,如果使用其它函数,譬如to_days,会报如下错误:“ERROR 1486 (HY000): Constant, random or timezone-dependent expressions in (sub)partitioning function are not allowed”。

而且官方文档中也提到“Any other expressions involving TIMESTAMP values are not permitted. (See Bug #42849.)”。

下面来测试一下基于UNIX_TIMESTAMP函数的RANGE分区方案,看其能否实现分区裁剪。

针对TIMESTAMP的分区方案

创表语句如下:

CREATE TABLE my_range_timestamp (
    id INT,
    hiredate TIMESTAMP
)
PARTITION BY RANGE ( UNIX_TIMESTAMP(hiredate) ) (
    PARTITION p1 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-02 00:00:00') ),
    PARTITION p2 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-03 00:00:00') ),
    PARTITION p3 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-04 00:00:00') ),
    PARTITION p4 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-05 00:00:00') ),
    PARTITION p5 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-06 00:00:00') ),
    PARTITION p6 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-07 00:00:00') ),
    PARTITION p7 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-08 00:00:00') ),
    PARTITION p8 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-09 00:00:00') ),
    PARTITION p9 VALUES LESS THAN ( UNIX_TIMESTAMP('2017-12-10 00:00:00') ),
    PARTITION p10 VALUES LESS THAN (UNIX_TIMESTAMP('2017-12-11 00:00:00') )
);

插入数据并查看上述查询的执行计划

mysql> insert into my_range_timestamp select * from test;
Query OK, 1000000 rows affected (13.25 sec)
Records: 1000000  Duplicates: 0  Warnings: 0

mysql> explain partitions select * from my_range_timestamp where hiredate >= '20171207124503' and hiredate<='20171210111230';
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
| id | select_type | table             | partitions   | type | possible_keys | key  | key_len | ref  | rows   | Extra       |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
|  1 | SIMPLE      | my_range_timestamp | p7,p8,p9,p10 | ALL  | NULL          | NULL | NULL    | NULL | 400448 | Using where |
+----+-------------+-------------------+--------------+------+---------------+------+---------+------+--------+-------------+
1 row in set (0.00 sec)

同样也能实现分区裁剪。

在5.7版本之前,对于DATA和DATETIME类型的列,如果要实现分区裁剪,只能使用YEAR() 和TO_DAYS()函数,在5.7版本中,又新增了TO_SECONDS()函数。

LIST 分区

LIST分区

LIST分区和RANGE分区类似,区别在于LIST是枚举值列表的集合,RANGE是连续的区间值的集合。二者在语法方面非常的相似。同样建议LIST分区列是非null列,否则插入null值如果枚举列表里面不存在null值会插入失败,这点和其它的分区不一样,RANGE分区会将其作为最小分区值存储,HASH\KEY分为会将其转换成0存储,主要LIST分区只支持整形,非整形字段需要通过函数转换成整形.

create table t_list( 
  a int(11), 
  b int(11) 
  )(partition by list (b) 
  partition p0 values in (1,3,5,7,9), 
  partition p1 values in (2,4,6,8,0) 
  );

Hash 分区

我们在实际工作中经常遇到像会员表的这种表。并没有明显可以分区的特征字段。但表数据有非常庞大。为了把这类的数据进行分区打散mysql 提供了hash分区。基于给定的分区个数,将数据分配到不同的分区,HASH分区只能针对整数进行HASH,对于非整形的字段只能通过表达式将其转换成整数。表达式可以是mysql中任意有效的函数或者表达式,对于非整形的HASH往表插入数据的过程中会多一步表达式的计算操作,所以不建议使用复杂的表达式这样会影响性能。

Hash分区表的基本语句如下:

CREATE TABLE my_member (
    id INT NOT NULL,
    fname VARCHAR(30),
    lname VARCHAR(30),
    created DATE NOT NULL DEFAULT '1970-01-01',
    separated DATE NOT NULL DEFAULT '9999-12-31',
    job_code INT,
    store_id INT
)
PARTITION BY HASH(id)
PARTITIONS 4;

注意:

  1. HASH分区可以不用指定PARTITIONS子句,如上文中的PARTITIONS 4,则默认分区数为1。
  2. 不允许只写PARTITIONS,而不指定分区数。
  3. 同RANGE分区和LIST分区一样,PARTITION BY HASH (expr)子句中的expr返回的必须是整数值。
  4. HASH分区的底层实现其实是基于MOD函数。譬如,对于下表

CREATE TABLE t1 (col1 INT, col2 CHAR(5), col3 DATE) PARTITION BY HASH( YEAR(col3) ) PARTITIONS 4; 如果你要插入一个col3为“2017-09-15”的记录,则分区的选择是根据以下值决定的:

MOD(YEAR(‘2017-09-01’),4) = MOD(2017,4) = 1

LINEAR HASH分区

LINEAR HASH分区是HASH分区的一种特殊类型,与HASH分区是基于MOD函数不同的是,它基于的是另外一种算法。

格式如下:

CREATE TABLE my_members (
    id INT NOT NULL,
    fname VARCHAR(30),
    lname VARCHAR(30),
    hired DATE NOT NULL DEFAULT '1970-01-01',
    separated DATE NOT NULL DEFAULT '9999-12-31',
    job_code INT,
    store_id INT
)
PARTITION BY LINEAR HASH( id )
PARTITIONS 4;

说明: 它的优点是在数据量大的场景,譬如TB级,增加、删除、合并和拆分分区会更快,缺点是,相对于HASH分区,它数据分布不均匀的概率更大。

KEY分区

KEY分区其实跟HASH分区差不多,不同点如下:

  1. KEY分区允许多列,而HASH分区只允许一列。
  2. 如果在有主键或者唯一键的情况下,key中分区列可不指定,默认为主键或者唯一键,如果没有,则必须显性指定列。
  3. KEY分区对象必须为列,而不能是基于列的表达式。
  4. KEY分区和HASH分区的算法不一样,PARTITION BY HASH (expr),MOD取值的对象是expr返回的值,而PARTITION BY KEY (column_list),基于的是列的MD5值。

格式如下:

CREATE TABLE k1 (
    id INT NOT NULL PRIMARY KEY,    
    name VARCHAR(20)
)
PARTITION BY KEY()
PARTITIONS 2;

在没有主键或者唯一键的情况下,格式如下:

CREATE TABLE tm1 (
    s1 CHAR(32)
)
PARTITION BY KEY(s1)
PARTITIONS 10;

总结:

  1. MySQL分区中如果存在主键或唯一键,则分区列必须包含在其中。
  2. 对于原生的RANGE分区,LIST分区,HASH分区,分区对象返回的只能是整数值。
  3. 分区字段不能为NULL,要不然怎么确定分区范围呢,所以尽量NOT NULL

PgSQL · 应用案例 · 流式计算与异步消息在阿里实时订单监测中的应用

$
0
0

背景

在很多业务系统中,为了定位问题、运营需要、分析需要或者其他需求,会在业务中设置埋点,记录用户的行为在业务系统中产生的日志,也叫FEED日志。

比如订单系统、在业务系统中环环相扣,从购物车、下单、付款、发货,收货(还有纠纷、退款等等),一笔订单通常会产生若干相关联的记录。

每个环节产生的属性可能是不一样的,有可能有新的属性产生,也有可能变更已有的属性值。

为了便于分析,通常有必要将订单在整个过程中产生的若干记录(若干属性),合并成一条记录(订单大宽表)。

通常业务系统会将实时产生的订单FEED数据写入消息队列,消息队列使得数据变成了流动的数据:

《从人类河流文明 洞察 数据流动的重要性》

方案一、RDS PG + OSS + HDB PG 分钟清洗和主动检测

数据通过消息队列消费后,实时写入RDS PG,在RDS PG进行订单FEED的合并,写入OSS外部表。(支持压缩格式,换算成裸数据的写入OSS的速度约100MB/s/会话)

HDB PG从OSS外部表读取(支持压缩格式,换算成裸数据的读取OSS的速度约100MB/s/数据节点),并将订单FEED数据合并到全量订单表。

pic

《打造云端流计算、在线业务、数据分析的业务数据闭环 - 阿里云RDS、HybridDB for PostgreSQL最佳实践》

数据进入HDB PG后,通过规则SQL,从全量订单表中,挖掘异常数据(或者分析)。

通过这种方案,实现了海量订单FEED数据的分钟级准实时分析。

这个方案已支撑了双十一业务,超高吞吐、低延迟,丝般柔滑。

方案二、毫秒级FEED监测及实时反馈方案

技术永远是为业务服务的,分钟级延迟虽然说已经很高了,但是在一些极端情况下,可能需要更低的延迟。

实际上RDS PostgreSQL还有更强的杀手锏,可以实现毫秒级的异常FEED数据发现和反馈。

流式处理+异步消息,方法如下:

1、通过触发机制结合异步消息通道实现。

pic

2、通过pipeline,流式SQL结合异步消息通道实现。

pic

应用程序监听消息通道(listen channel),数据库则将异常数据写入到消息通道(notify channel, message)。实现异常数据的主动异步推送。

毫秒级FEED监测与反馈架构设计

之前不做毫秒级的FEED监测,还有一个原因是HBASE的合并延迟较高,导致流计算在补齐字段时异常。使用RDS PG来实现异常监测,完全杜绝了补齐的问题,因为在RDS PG就包含了全字段,不存在补齐的需求。

pic

RDS PG设计

1、分实例,提高系统级吞吐。(例如单实例处理能力是15万行/s,那么100个实例,可以支撑1500万行/s的实时处理。)

例如:

DB0, DB1, DB2, DB3, ..., DB255    

映射关系:

db0, host?, port?    
    
db1, host?, port?    
    
...    

2、实例内使用分表,提高单实例并行处理吞吐。当规则众多时,分表可以提高单实例的规则处理吞吐。

例如

tbl0, tbl1, tbl2, ..., tbl127    
    
tbl128, tbl129, tbl130, ..., tbl255    

映射关系:

tbl0, db?    
    
tbl1, db?    
    
...    

HDB PG设计

HDB PG依旧保留,用于PB级数据量的海量数据实时分析。

数据通路依旧采用OSS,批量导入的方式。

DEMO

1、创建订单feed全宽表(当然,我们也可以使用jsonb字段来存储所有属性。因为PostgreSQL支持JSONB类型哦。PostgreSQL支持的多值类型还有hstore, xml等。)

create table feed(id int8 primary key, c1 int, c2 int, c3 int, c4 int, c5 int, c6 int, c7 int, c8 int, c9 int, c10 int, c11 int, c12 int);    

2、订单FEED数据的写入,例如A业务系统,写入订单的c1,c2字段。B业务系统,写入订单的c3,c4字段。……

使用on conflict do something语法,进行订单属性的合并。

insert into feed (id, c1, c2) values (2,2,30001) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    
    
insert into feed (id, c3, c4) values (2,99,290001) on conflict (id) do update set c3=excluded.c3, c4=excluded.c4 ;    

3、建立订单FEED的实时监测规则,当满足条件时,向PostgreSQL的异步消息中发送消息。监听该通道的APP,循环从异步消息获取数据,即可满足消息的实时消费。

规则可以保留在TABLE中,也可以写在触发器代码中,也可以写在UDF代码中。

3.1、如果数据是批量写入的,可以使用语句级触发器,降低触发器函数被调用的次数,提高写入吞吐。

create or replace function tg1() returns trigger as $$    
declare    
begin     
  -- 规则定义,实际使用时,可以联合规则定义表    
  -- c2大于1000时,发送异步消息    
  perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(inserted)) from inserted where c2>1000;      
    
  -- 多个规则,写单个notify的方法。    
  --   perform pg_notify(    
  --                    'channel_1',      
  --                   case     
  --                    when c2>1000 then 'Resone:c2 overflow::'||row_to_json(inserted)     
  --                    when c1>200 then 'Resone:c1 overflow::'||row_to_json(inserted)     
  --                   end    
  --                  )     
  --   from inserted     
  --   where     
  --     c2 > 1000     
  --     or c1 > 200;      
    
  -- 多个规则,可以写多个notify,或者合并成一个NOTIFY。    
    
  return null;    
end;    
$$ language plpgsql strict;    

3.2、如果数据是单条写入的,可以使用行级触发器。(本例后面的压测使用这个)

create or replace function tg2() returns trigger as $$    
declare    
begin     
  -- 规则定义,实际使用时,可以联合规则定义表    
    
  -- c2大于9999时,发送异步消息    
  perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2>9999;      
    
  -- 多个规则,调用单个notify,写一个CHANNEL的方法。    
  --   perform pg_notify(    
  --                    'channel_1',      
  --                   case     
  --                    when c2>1000 then 'Resone:c2 overflow::'||row_to_json(NEW)     
  --                    when c1>200 then 'Resone:c1 overflow::'||row_to_json(NEW)     
  --                   end    
  --                  )     
  --   where     
  --     NEW.c2 > 10000     
  --     or NEW.c1 > 200;      
    
  -- 多个规则,调用单个notify,写多个CHANNEL的方法。    
  --   perform pg_notify(    
  --                   case     
  --                    when c2>1000 then 'channel_1'     
  --                    when c1>200 then 'channel_2'     
  --                   end,    
  --                   case     
  --                    when c2>1000 then 'Resone:c2 overflow::'||row_to_json(NEW)     
  --                    when c1>200 then 'Resone:c1 overflow::'||row_to_json(NEW)     
  --                   end    
  --                  )     
  --   where     
  --     NEW.c2 > 1000     
  --     or NEW.c1 > 200;      
    
  -- 多个规则,可以写多个notify,或者合并成一个NOTIFY。    
  -- 例如    
  -- perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2 > 1000;    
  -- perform pg_notify('channel_2', 'Resone:c1 overflow::'||row_to_json(NEW)) where NEW.c1 > 200;    
    
  -- 也可以把规则定义在TABLE里面,实现动态的规则    
  -- 规则不要过于冗长,否则会降低写入的吞吐,因为是串行处理规则。    
  -- udf的输入为feed类型以及rule_table类型,输出为boolean。判断逻辑定义在UDF中。    
  -- perfrom pg_notify(channel_column, resone_column||'::'||row_to_json(NEW)) from rule_table where udf(NEW::feed, rule_table);    
    
  return null;    
end;    
$$ language plpgsql strict;    

3.3、如上代码中所述,规则可以定义在很多地方。

4、创建触发器。

4.1、语句级触发器(批量写入,建议采用)

create trigger tg1 after insert on feed REFERENCING NEW TABLE AS inserted for each statement execute procedure tg1();    
create trigger tg2 after update on feed REFERENCING NEW TABLE AS inserted for each statement execute procedure tg1();    

4.2、行级触发器(单步写入建议采用),(本例后面的压测使用这个)

create trigger tg1 after insert on feed for each row execute procedure tg2();    
create trigger tg2 after update on feed for each row execute procedure tg2();    

5、协商好通道名称。

6、应用端监听消息通道。

listen channel_1;    
    
接收消息:    
    
loop    
  sleep ?;    
  get 消息;    
end loop    

7、写入订单数据,每行数据都会实时过触发器,在触发器中写好了逻辑,当满足一些规则时,向协商好的消息通道发送消息。

postgres=# insert into feed (id, c1, c2) values (2,2,30001) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    
INSERT 0 1    

8、接收到的消息样本如下:

Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2,"c1":2,"c2":30001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    

9、批量插入

postgres=# insert into feed (id, c1, c2)  select id,random()*100, random()*1001 from generate_series(1,10000) t(id) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    
INSERT 0 10000    
Time: 59.528 ms    

一次接收到的样本如下:

Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":362,"c1":92,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4061,"c1":90,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4396,"c1":89,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":5485,"c1":72,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":6027,"c1":56,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":6052,"c1":91,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7893,"c1":84,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8158,"c1":73,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    

10、更新数据

postgres=# update feed set c1=1;    
UPDATE 10000    
Time: 33.444 ms    

接收到的异步消息样本如下:

Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":1928,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2492,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2940,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":2981,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4271,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4539,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7089,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7619,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8001,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8511,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8774,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":9394,"c1":1,"c2":1001,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 38445.    

压测1 - 单步实时写入

1、假设每1万条记录中,有一条异常记录需要推送,这样的频率算是比较现实的。

vi test.sql    
    
\set id random(1,10000000)    
\set c1 random(1,1001)    
\set c2 random(1,10000)    
insert into feed (id, c1, c2) values (:id, :c1, :c2) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    

2、压测结果,16.7万 行/s 处理吞吐。

transaction type: ./test.sql    
scaling factor: 1    
query mode: prepared    
number of clients: 56    
number of threads: 56    
duration: 120 s    
number of transactions actually processed: 20060111    
latency average = 0.335 ms    
latency stddev = 0.173 ms    
tps = 167148.009836 (including connections establishing)    
tps = 167190.475312 (excluding connections establishing)    
script statistics:    
 - statement latencies in milliseconds:    
         0.002  \set id random(1,10000000)    
         0.001  \set c1 random(1,1001)    
         0.000  \set c2 random(1,10000)    
         0.332  insert into feed (id, c1, c2) values (:id, :c1, :c2) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2 ;    

3、监听到的异步消息采样

postgres=# listen channel_1;    
LISTEN    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":3027121,"c1":393,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 738.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":5623104,"c1":177,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 758.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":3850742,"c1":365,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 695.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":5244809,"c1":55,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 716.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":4062585,"c1":380,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 722.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":8536437,"c1":560,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 695.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":7327211,"c1":365,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 728.    
Asynchronous notification "channel_1" with payload "Resone:c2 overflow::{"id":431739,"c1":824,"c2":10000,"c3":null,"c4":null,"c5":null,"c6":null,"c7":null,"c8":null,"c9":null,"c10":null,"c11":null,"c12":null}" received from server process with PID 731.    

单实例分表的schemaless设计

请参考如下用法或案例,目的是自动建表,自动分片。

《PostgreSQL 在铁老大订单系统中的schemaless设计和性能压测》

《PostgreSQL 按需切片的实现(TimescaleDB插件自动切片功能的plpgsql schemaless实现)》

《PostgreSQL schemaless 的实现》

《PostgreSQL 时序最佳实践 - 证券交易系统数据库设计 - 阿里云RDS PostgreSQL最佳实践》

压测2 - 单实例分表实时写入

1、创建订单feed全宽表模板表

create table feed(id int8 primary key, c1 int, c2 int, c3 int, c4 int, c5 int, c6 int, c7 int, c8 int, c9 int, c10 int, c11 int, c12 int);    

2、定义规则

create or replace function tg() returns trigger as $$    
declare    
begin     
  -- c2大于9999时,发送异步消息,  
  perform pg_notify('channel_1', 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2>9999;     
  
  -- 写入各个通道的例子,通过trigger parameter传入通道后缀(也可以写入单一通道,具体看设计需求)   
  -- perform pg_notify('channel_'||TG_ARGV[0], 'Resone:c2 overflow::'||row_to_json(NEW)) where NEW.c2>9999;      
    
  return null;    
end;    
$$ language plpgsql strict;    

3、定义分表

do language plpgsql $$  
declare  
begin  
  for i in 1..512 loop  
    execute 'create table feed'||i||'(like feed including all) inherits (feed)';  
    -- 创建触发器(采用行级触发) , 本例采用静态规则(when (...)),实际使用请使用动态规则,处理所有行   
    execute 'create trigger tg1 after insert on feed'||i||' for each row WHEN (NEW.c2>9999) execute procedure tg()';    
    execute 'create trigger tg2 after update on feed'||i||' for each row WHEN (NEW.c2>9999) execute procedure tg()';     
  end loop;  
end;  
$$;  

4、定义动态写入分表UDF(这个逻辑可以在应用层实现,本例只是演示单实例分表后的写吞吐能达到多少)

单条提交。

create or replace function ins(int,int8,int,int) returns void as $$  
declare  
begin  
  execute format('insert into feed%s (id,c1,c2) values (%s,%s,%s) on conflict (id) do update set c1=excluded.c1, c2=excluded.c2', $1, $2, $3, $4) ;    
end;  
$$ language plpgsql strict;   

批量提交。

create or replace function ins(int, int8) returns void as $$  
declare  
begin  
  execute format('insert into feed%s (id,c1,c2) %s on conflict (id) do update set c1=excluded.c1, c2=excluded.c2', $1, 'select id, random()*100, random()*10000 from generate_series('||$1||','||$1+1000||') t (id)') ;    
end;  
$$ language plpgsql strict;   

5、假设每1万条记录中,有一条异常记录需要推送,这样的频率算是比较现实的。

单条提交。

vi test.sql    
    
\set suffix random(1,512)  
\set id random(1,10000000)    
\set c1 random(1,1001)    
\set c2 random(1,10000)    
select ins(:suffix, :id, :c1, :c2);  

批量提交。

vi test.sql    
    
\set suffix random(1,512)  
\set id random(1,10000000)    
select ins(:suffix, :id);  

6、压测结果

单条提交, 15万 行/s处理吞吐。

相比单表单步写入略低,原因是采用了动态SQL(在UDF拼接分表),这部分逻辑放到APP端,性能有20%的提升。

transaction type: ./test.sql  
scaling factor: 1  
query mode: prepared  
number of clients: 112  
number of threads: 112  
duration: 120 s  
number of transactions actually processed: 18047334  
latency average = 0.744 ms  
latency stddev = 0.450 ms  
tps = 150264.463046 (including connections establishing)  
tps = 150347.026261 (excluding connections establishing)  
script statistics:  
 - statement latencies in milliseconds:  
         0.002  \set suffix random(1,512)  
         0.001  \set id random(1,10000000)    
         0.001  \set c1 random(1,1001)    
         0.000  \set c2 random(1,10000)    
         0.742  select ins(:suffix, :id, :c1, :c2);  

批量提交(1000行/批),117万 行/s处理吞吐。

批量提交有了质的提升。

transaction type: ./test.sql  
scaling factor: 1  
query mode: prepared  
number of clients: 56  
number of threads: 56  
duration: 120 s  
number of transactions actually processed: 140508  
latency average = 47.820 ms  
latency stddev = 17.175 ms  
tps = 1169.851558 (including connections establishing)  
tps = 1170.150203 (excluding connections establishing)  
script statistics:  
 - statement latencies in milliseconds:  
         0.002  \set suffix random(1,512)  
         0.000  \set id random(1,10000000)    
        47.821  select ins(:suffix, :id);  

jdbc 异步消息使用例子

https://jdbc.postgresql.org/documentation/81/listennotify.html

import java.sql.*;    
    
public class NotificationTest {    
    
        public static void main(String args[]) throws Exception {    
                Class.forName("org.postgresql.Driver");    
                String url = "jdbc:postgresql://localhost:5432/test";    
    
                // Create two distinct connections, one for the notifier    
                // and another for the listener to show the communication    
                // works across connections although this example would    
                // work fine with just one connection.    
                Connection lConn = DriverManager.getConnection(url,"test","");    
                Connection nConn = DriverManager.getConnection(url,"test","");    
    
                // Create two threads, one to issue notifications and    
                // the other to receive them.    
                Listener listener = new Listener(lConn);    
                Notifier notifier = new Notifier(nConn);    
                listener.start();    
                notifier.start();    
        }    
    
}    
    
class Listener extends Thread {    
    
        private Connection conn;    
        private org.postgresql.PGConnection pgconn;    
    
        Listener(Connection conn) throws SQLException {    
                this.conn = conn;    
                this.pgconn = (org.postgresql.PGConnection)conn;    
                Statement stmt = conn.createStatement();    
                stmt.execute("LISTEN mymessage");    
                stmt.close();    
        }    
    
        public void run() {    
                while (true) {    
                        try {    
                                // issue a dummy query to contact the backend    
                                // and receive any pending notifications.    
                                Statement stmt = conn.createStatement();    
                                ResultSet rs = stmt.executeQuery("SELECT 1");    
                                rs.close();    
                                stmt.close();    
    
                                org.postgresql.PGNotification notifications[] = pgconn.getNotifications();    
                                if (notifications != null) {    
                                        for (int i=0; i<notifications.length; i++) {    
                                                System.out.println("Got notification: " + notifications[i].getName());    
                                        }    
                                }    
    
                                // wait a while before checking again for new    
                                // notifications    
                                Thread.sleep(500);    
                        } catch (SQLException sqle) {    
                                sqle.printStackTrace();    
                        } catch (InterruptedException ie) {    
                                ie.printStackTrace();    
                        }    
                }    
        }    
    
}    
    
class Notifier extends Thread {    
    
        private Connection conn;    
    
        public Notifier(Connection conn) {    
                this.conn = conn;    
        }    
    
        public void run() {    
                while (true) {    
                        try {    
                                Statement stmt = conn.createStatement();    
                                stmt.execute("NOTIFY mymessage");    
                                stmt.close();    
                                Thread.sleep(2000);    
                        } catch (SQLException sqle) {    
                                sqle.printStackTrace();    
                        } catch (InterruptedException ie) {    
                                ie.printStackTrace();    
                        }    
                }    
        }    
    
}    

libpq 异步消息的使用方法

https://www.postgresql.org/docs/10/static/libpq-notify.html

触发器的用法

https://www.postgresql.org/docs/10/static/sql-createtrigger.html

《PostgreSQL 触发器 用法详解 1》

《PostgreSQL 触发器 用法详解 2》

注意事项

1、异步消息快速接收,否则会占用实例 $PGDATA/pg_notify的目录空间。

2、异步消息上限,没有上限,和存储有个。

buffer大小:

/*    
 * The number of SLRU page buffers we use for the notification queue.    
 */    
#define NUM_ASYNC_BUFFERS       8    

3、异步消息可靠性,每个异步消息通道,PG都会跟踪监听这个通道的会话已接收到的消息的位置偏移。

新发起的监听,只从监听时该通道的最后偏移开始发送,该偏移之前的消息不会被发送。

消息接收后,如果没有任何监听需要,则会被清除。

监听消息通道的会话,需要持久化,也就是说会话断开的话,(未接收的消息,以及到会话重新监听这段时间,新产生的消息,都收不到)

4、如果需要强可靠性(替换掉异步消息,使用持久化的模式)

方法:触发器内pg_notify改成insert into feedback_table ....;

持久化消息的消费方法,改成如下(阅后即焚模式):

with t1 as (select ctid from feedback_table order by crt_time limit 100)     
  delete from feedback_table where     
    ctid = any (array(select ctid from t1))    
    returning *;    

持久化消息,一样能满足10万行以上的消费能力(通常异常消息不会那么多,所以这里可以考虑使用单个异常表,多个订单表)。

只不过会消耗更多的RDS PG的IOPS,(产生写 WAL,VACUUM WAL。)

其他

1、已推送的异常,当数据更新后,可能会被再次触发,通过在逻辑中对比OLD value和NEW value可以来规避这个问题。本文未涉及。实际使用是可以改写触发器代码。

实时计算处理吞吐

1、RDS PostgreSQL 单实例处理吞吐达到了 117万 行/s。性价比超级棒。

2、100个RDS PostgreSQL,可以轻松达到 1亿 行/s (60亿/分钟) 的处理吞吐。宇宙无敌了。

参考

《在PostgreSQL中实现update | delete limit - CTID扫描实践 (高效阅后即焚)》

《(流式、lambda、触发器)实时处理大比拼 - 物联网(IoT)\金融,时序处理最佳实践》

《PostgreSQL 10.0 preview 功能增强 - 触发器函数内置中间表》

https://www.postgresql.org/docs/10/static/sql-createtrigger.html

https://jdbc.postgresql.org/documentation/81/listennotify.html

https://www.postgresql.org/docs/10/static/libpq-notify.html

《(流式、lambda、触发器)实时处理大比拼 - 物联网(IoT)\金融,时序处理最佳实践》

Viewing all 687 articles
Browse latest View live