对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成。实现分区的代码实际上是对一组底层表的句柄对象(Handler Object)的封装。对分区表的请求,都会通过句柄对象转化成对存储引擎的接口调用。所以分区对于 SQL 层来说是一个完全封装底层实现的黑盒子,对应用是透明的,但是从底层的文件系统来看就很容易发现,每一个分区表都有一个使用#分隔命名的表文件。
MySQL 实现分区表的方式——对底层表的封装——意味着索引也是按照分区的子表定义的,而没有全局索引。这和 Oracle 不同,在 Oracle 中可以更加灵活地定义索引和表是否进行分区。
MySQL 在创建表时使用 PARTITION BY子句定义每个分区存放的数据。在执行查询的时候,优化器会根据分区定义过滤那些没有我们需要数据的分区,这样查询就无须扫描所有分区——只需要查找包含需要数据的分区就可以了。
分区的一个主要目的是将数据按照一个较粗的粒度分在不同的表中。这样做可以将相关的数据存放在一起,另外,如果想一次批量删除整个分区的数据也会变得很方便。
在下面的场景中,分区可以起到非常大的作用∶
MySQL的分区实现非常复杂,我不打算介绍实现的全部细节。这里我们将专注在分区性能方面,所以如果想了解更多的关于分区的基础知识,我建议阅读 MySQL官方手册中的"分区"一节,其中介绍了很多分区相关的基础知识。另外,还可以阅读CREATE TABLE、SHOW CREATE TABLE、ALTER TABLE和 INFORMATION SCHEMA.PARTITIONS、EXPLAIN关于分区部分的介绍。分区特性使得 CREATE TABLE和 ALTER TABLE命令变得更加复杂了。
分区表本身也有一些限制,下面是其中比较重要的几点∶
分区表由多个相关的底层表实现,这些底层表也是由句柄对象(Handler object)表示,所以我们也可以直接访问各个分区。存储引擎管理分区的各个底层表和管理普通表一样(所有的底层表都必须使用相同的存储引擎),分区表的素引只是在各个底层表上各自加上一个完全相同的索引。从存储引擎的角度来看,底层表和一个普通表没有任何不同,存储引擎也无须知道这是一个普通表还是一个分区表的一部分。
分区表上的操作按照下面的操作逻辑进行;
当查询一个分区表的时候,分区层先打开并锁住所有的底层表,优化器先判断是否可以过滤部分分区,然后再调用对应的存储引擎接口访问各个分区的数据。
当写入一条记录时,分区层先打开并锁住所有的底层表,然后确定哪个分区接收这条记录,再将记录写入对应底层表。
当删除一条记录时,分区层先打儿并锁住所有的底层表,然后确定数据对应的分区,最后对相应底层表进行删除操作。
当更新一条记录时,分区层先打开并锁住所有的底层表,MySQL 先确定需要更新的记录在哪个分区,然后取出数据并更新,再判断更新后的数据应该放在哪个分区,最后对底层表进行写入操作,并对原数据所在的底层表进行删除操作。
有些操作是支持过滤的。例如,当删除一条记录时,MySQL 需要先找到这条记录,如果 WHERE条件恰好和分区表达式匹配,就可以将所有不包含这条记录的分区都过滤掉。这对UPDATE语句同样有效。如果是 INSERT操作,则本身就是只命中一个分区,其他分区都会被过滤掉。MySQL 先确定这条记录属于哪个分区,再将记录写入对应的底层分区表,无须对任何其他分区进行操作。
虽然每个操作都会"先打开并锁住所有的底层表",但这并不是说分区表在处理过程中是锁住全表的。如果存储引擎能够自己实现行级锁,例如InnoDB,则会在分区层释放对应表锁。这个加锁和解锁过程与普通 InnoDB上的查询类似。
后面我们会通过一些例子来看看,当访问一个分区表的时候,打开和锁住所有底层表的代价及其带来的后果。
MySQL 支持多种分区表。我们看到最多的是根据范围进行分区,每个分区存储落在某个范围的记录,分区表达式可以是列,也可以是包含列的表达式。例如,下表就可以将每一年的销售额存放在不同的分区里;
CREATE TABLE sales(
order_date DATETIME NOT NULL, --Other columns omitted
)ENGINE=InoDB PARTITION BY RANGE(YEAR(order_date))(
PARTITION p_2010 VALUES LESS THAN(2010),
PARTITION p_2011 VALUES LESS THAN(2011),
PARTITION p_2012 VALUES LESS THAN(2012),
PARTITION p_catchall VALUES LESS THAN MAXVALUE);
PARTITION分区子句中可以使用各种函数。但有一个要求,表达式返回的值要是一个确定的整数,且不能是一个常数。这里我们使用函数 YEAR(),也可以使用任何其他的函数,如 TO DAYS()。根据时间间隔进行分区,是一种很常见的分区方式,后面我们还会再回过头来看这个例子,看看如何优化这个例子来避免一些问题。
MySQL还支持键值、哈希和列表分区,这其中有些还支持子分区,不过我们在生产环境中很少见到。在 MySQL 5.5中,还可以使用RANGE COLUMNS类型的分区,这样即使是基于时间的分区也无须再将其转化成一个整数,后面将详细介绍。
在我们看过的一个子分区的案例中,对一个类似于前面我们设计的按时间分区的InnoDB 表,系统通过子分区可降低索引的互斥访问的竞争。最近一年的分区的数据会被非常频繁地访问,这会导致大量的互斥量的竞争。使用哈希子分区可以将数据切成多个小片,大大降低互斥量的竟争问题。
我们还看到的一些其他的分区技术包括;
假设我们希望从一个非常大的表中查询出一段时间的记录,而这个表中包含了很多年的历史数据,数据是按照时间排序的,例如,希望查询最近几个月的数据,这大约有10亿条记录。可能过些年本书会过时,不过我们还是假设使用的是 2012年的硬件设备,而原表中有10TB的数据,这个数据量远大于内存,并且使用的是传统硬盘,不是闪存(多数 SSD也没有这么大的空间)。
你打算如何查询这个表?如何才能更高效?
首先很肯定;因为数据量巨大,肯定不能在每次查询的时候都扫描全表。考虑到索引在空间和维护上的消耗,也不希望使用索引。即使真的使用索引,你会发现数据并不是按照想要的方式聚集的,而且会有大量的碎片产生,最终会导致一个查询产生成千上万的随机 I/O,应用程序也随之僵死。情况好一点的时候,也许可以通过一两个索引解决一些问题。不过多数情况下,索引不会有任何作用。这时候只有两条路可选;让所有的查询都只在数据表上做顺序扫描,或者将数据表和索引全部都缓存在内存里。
这里需要再陈述一遍;在数据量超大的时候,B-Tree索引就无法起作用了。除非是索引覆盖查询,否则数据库服务器需要根据索引扫描的结果回表,查询所有符合条件的记录,如果数据量巨大,这将产生大量随机 I/O,随之,数据库的响应时间将大到不可接受的程度。另外,索引维护(磁盘空间、I/O操作)的代价也非常高。有些系统,如Infobright,意识到这一点,于是就完全放弃使用 B-Tree 索引,而选择了一些更粗粒度的但消耗更少的方式检索数据,例如在大量数据上只索引对应的一小块元数据。
这正是分区要做的事情。理解分区时还可以将其当作索引的最初形态。以代价非常小的方式定位到需要的数据在哪一片"区域"。在这片"区域"中,你可以做顺序扫描,可以建索引,还可以将数据都缓存到内存,等等。因为分区无须额外的数据结构记录每个分区有哪些数据-—分区不需要精确定位每条数据的位置,也就无须额外的数据结构——所以其代价非常低。只需要一个简单的表达式就可以表达每个分区存放的是什么数据。
为了保证大数据量的可扩展性,一般有下面两个策略∶
1. 全量扫描数据,不要任何索引
可以使用简单的分区方式存放表,不要任何索引,根据分区的规则大致定位需要的数据位置。只要能够使用WHERE条件,将需要的数据限制在少数分区中,则效率是很高的。当然,也需要做一些简单的运算保证查询的响应时间能够满足需求。使用该策略假设不用将数据完全放入到内存中,同时还假设需要的数据全都在磁盘上,因为内存相对很小,数据很快会被挤出内存,所以缓存起不了任何作用。这个策略适用于以正常的方式访问大量数据的时候。警告;后面我们会详细解释,必须将查询需要扫描的分区个数限制在一个很小的数量。
2. 索引 数据,并分离热点
如果数据有明显的"热点",而且除了这部分数据,其他数据很少被访问到,那么可以将这部分热点数据单独放在一个分区中,让这个分区的数据能够有机会都缓存在内存中。这样查询就可以只访问一个很小的分区表,能够使用素引,也能够有效地使用缓存。
#创建分区表
tbs@localhost:[dbt3]>create table qu(id int)engine=innodb partition by range (id)(partition p0 valueslues less than (10), partition p1 values less than (20));
Query OK, 0 rows affected (0.00 sec)
#插入数据
tbs@localhost:[dbt3]>insert into qu select 9;
Query OK, 1 row affected (0.01 sec)
Records: 1 Duplicates: 0 Warnings: 0
tbs@localhost:[dbt3]>insert into qu select 10;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0
tbs@localhost:[dbt3]>insert into qu select 15;
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0
#查看文件
-rw-r----- 1 mysql mysql 8.4K 8月 20 11:08 qu.frm
-rw-r----- 1 mysql mysql 96K 8月 20 11:08 qu#P#p0.ibd
-rw-r----- 1 mysql mysql 96K 8月 20 11:08 qu#P#p1.ibd
-rw-r----- 1 mysql mysql 8.5K 7月 8 10:42 region.frm
-rw-r----- 1 mysql mysql 96K 7月 8 10:42 region.ibd
#查看分区内容
tbs@localhost:[dbt3]>select * from information_schema.PARTITIONS where table_schema=database() and table_name='qu'\G;
*************************** 1. row ***************************
TABLE_CATALOG: def
TABLE_SCHEMA: dbt3
TABLE_NAME: qu
PARTITION_NAME: p0
SUBPARTITION_NAME: NULL
PARTITION_ORDINAL_POSITION: 1
SUBPARTITION_ORDINAL_POSITION: NULL
PARTITION_METHOD: RANGE
SUBPARTITION_METHOD: NULL
PARTITION_EXPRESSION: id
SUBPARTITION_EXPRESSION: NULL
PARTITION_DESCRIPTION: 10
TABLE_ROWS: 1
AVG_ROW_LENGTH: 16384
DATA_LENGTH: 16384
MAX_DATA_LENGTH: NULL
INDEX_LENGTH: 0
DATA_FREE: 0
CREATE_TIME: 2021-08-20 11:08:56
UPDATE_TIME: 2021-08-20 11:10:33
CHECK_TIME: NULL
CHECKSUM: NULL
PARTITION_COMMENT:
NODEGROUP: default
TABLESPACE_NAME: NULL
*************************** 2. row ***************************
TABLE_CATALOG: def
TABLE_SCHEMA: dbt3
TABLE_NAME: qu
PARTITION_NAME: p1
SUBPARTITION_NAME: NULL
PARTITION_ORDINAL_POSITION: 2
SUBPARTITION_ORDINAL_POSITION: NULL
PARTITION_METHOD: RANGE
SUBPARTITION_METHOD: NULL
PARTITION_EXPRESSION: id
SUBPARTITION_EXPRESSION: NULL
PARTITION_DESCRIPTION: 20
TABLE_ROWS: 2
AVG_ROW_LENGTH: 8192
DATA_LENGTH: 16384
MAX_DATA_LENGTH: NULL
INDEX_LENGTH: 0
DATA_FREE: 0
CREATE_TIME: 2021-08-20 11:08:56
UPDATE_TIME: 2021-08-20 11:10:44
CHECK_TIME: NULL
CHECKSUM: NULL
PARTITION_COMMENT:
NODEGROUP: default
TABLESPACE_NAME: NULL
2 rows in set (0.00 sec)
TABLE_ROWS列反映了每个分区中记录的数量。由于之前向表中插入了9、10、15三
条记录,因此可以看到,当前分区p0中有1条记录、分区p1中有2条记录。PARTITION_ METHOD表示分区的类型,这里显示的是RANGE。
tbs@localhost:[dbt3]>insert into qu select 30;
ERROR 1526 (HY000): Table has no partition for value 30
tbs@localhost:[dbt3]>
对于上述问题,我们可以对分区添加一个MAXVALUE值的分区。MAXVALUE可以理解为正无穷,因此所有大于等于20并且小于MAXVALUE的值放入p2分区∶
tbs@localhost:[dbt3]>alter table qu add partition(partition p2 values less than maxvalue);
Query OK, 0 rows affected (0.00 sec)
Records: 0 Duplicates: 0 Warnings: 0
tbs@localhost:[dbt3]>
tbs@localhost:[dbt3]>insert into qu select 30;
Query OK, 1 row affected (0.01 sec)
Records: 1 Duplicates: 0 Warnings: 0
RANGE分区主要用于日期列的分区,如对于销售类的表,可以根据年来分区存放销售
记录,如以下所示的分区表sales;
tbs@localhost:[dbt3]>CREATE TABLE sales(money INT UNSIGNED NOT NULL, date datetime )engine=INNODB PARTITION by RANGE (YEAR(date)) ( PARTITION p2008 VALUES less than(2009), PARTITION p2009 VALUES less than(2010), PARTITION p2010 VALUES less than(2011));
Query OK, 0 rows affected (0.00 sec)
查询优化,分区查询
tbs@localhost:[dbt3]>EXPLAIN PARTITIONs SELECT * FROM sales WHERE date>='2008-01-01' AND date<='2008-12-31'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: sales
partitions: p2008
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 3
filtered: 33.33
Extra: Using where
1 row in set, 2 warnings (0.00 sec)
删除优化吗,删除分区
tbs@localhost:[dbt3]>ALter TABLE sales DROP PARTITION p2008;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0