
MySQL分区表的核心思路是把一张逻辑上的大表按某种规则拆分成多个独立的物理子表,但这些子表仍然被同一张表的命名空间所包裹,对应用层完全透明。这种物理拆分带来的最大好处就是查询时可以利用分区裁剪:优化器根据WHERE条件中的分区键,只访问相关的分区,而不是像传统单表那样进行全表扫描。从MySQL 5.1开始引入的分区功能,到8.0版本已经相当成熟,支持RANGE、LIST、HASH和KEY四种基本类型,以及它们的子类型组合。不过千万注意,分区不是银弹,如果选错了分区键,或者在不合适的场景中生硬使用,反而会拖累写入性能,甚至让某些查询比不分区时更慢。
分区之前需要搞清的几个前提
决定使用分区之前,必须确认你的业务查询模式存在明显的“分而治之”特征。例如,一张订单表每天新增数十万行,而绝大多数查询都带有created_at时间范围条件,那么按时间分区就非常自然。相反,如果查询条件在多个列上频繁变化,分区裁剪很难生效,分区的意义就会大打折扣。另外,分区表对唯一约束有严格限制:所有用于唯一索引(包括主键)的列必须包含分区表达式里的所有列。换句话说,如果你打算按created_at进行RANGE分区,那么主键和所有唯一键都必须把created_at加进去。这一点经常在设计阶段被忽略,导致后期不得不调整索引,甚至放弃分区。
还有一点容易被低估的是分区数量的影响。MySQL对单表的分区个数上限是8192个(实际受操作系统文件描述符限制),但这并不意味着越多越好。当分区数量膨胀到几百个以上时,每次打开表都需要检查所有分区定义,DDL操作和查询优化器的开销会明显上升。因此,对于像日志表这类持续写入的场景,通常会配合定时任务自动创建新分区并删除老旧分区,把活跃分区数量控制在一个合理范围内。
RANGE分区:按时间维度切分数据
RANGE分区是最常见的类型,特别适合处理带有自然增长属性列的数据,比如日期、订单号、用户ID等。语法上通过PARTITION BY RANGE(expr)定义,然后逐个声明每个分区的上界,值小于该上界的行都会落入对应分区。最末一个分区通常使用VALUES LESS THAN MAXVALUE来兜底,保证任何超出预定范围的数据也能顺利插入。
CREATE TABLE order_log (
id BIGINT NOT NULL,
buyer_id INT NOT NULL,
amount DECIMAL(12,2),
created_at DATE NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE(TO_DAYS(created_at)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
上面这个定义中,主键必须包含created_at,因为它是分区键。查询2024年2月份的订单时,优化器只扫描p202402分区。我们可以通过EXPLAIN输出中的partitions列验证这一点。需要注意的是,RANGE分区只能包含VALUES LESS THAN子句,且分区顺序必须按值递增,不能出现交叉,否则建表会直接报错。
维护RANGE分区表的常见操作是添加分区和删除分区。对于按月份分区的表,可以使用REORGANIZE PARTITION拆分MAXVALUE分区来加入新月份,或者用DROP PARTITION直接扔掉过期数据。后者瞬间完成,因为它本质上是在文件系统层面删除对应的.ibd文件,比执行DELETE语句快得多,且不会产生大量事务日志。
LIST分区:按离散值归类
LIST分区和RANGE类似,只是判断条件从范围变成了离散列表。它非常适合按地区、状态、类别等有限且固定的枚举值进行切分。例如,一张用户表需要按省份存储,不同省份的数据落入不同分区,这时就可以用LIST分区。
CREATE TABLE user_province (
id BIGINT NOT NULL,
name VARCHAR(50),
province_code CHAR(2) NOT NULL,
created_time DATETIME,
PRIMARY KEY (id, province_code)
) PARTITION BY LIST(province_code) (
PARTITION p_east VALUES IN ('SH', 'JS', 'ZJ'),
PARTITION p_south VALUES IN ('GD', 'GX', 'HN'),
PARTITION p_west VALUES IN ('SC', 'CQ', 'YN'),
PARTITION p_others VALUES IN (DEFAULT)
);
LIST分区中最特殊的就是DEFAULT分区,用来接收所有未在其它分区中明确列出的值。这一点与RANGE的MAXVALUE作用类似。如果创建LIST分区时没有提供DEFAULT分区,那么插入一个不在任何列表中的province_code就会直接失败。此外,使用LIST分区时,对分区键的修改可能触发行在分区之间的移动,这在MySQL 8.0中是被允许的,但会消耗额外的资源,操作期间需要持有锁。
HASH分区:让数据均匀分布
当找不到像时间或地区这样明确的切分边界时,HASH分区就派上用场了。它的目标不是实现业务上的隔离,而是尽可能将数据均匀打散到各个分区中,以避免某个分区过热。HASH分区可以指定表达式,MySQL会对表达式结果做哈希运算再模上分区数,决定最终落点。建表时使用PARTITION BY HASH(expr) PARTITIONS n;即可。
CREATE TABLE click_log (
id BIGINT NOT NULL,
user_id INT NOT NULL,
click_time DATETIME,
PRIMARY KEY (id, user_id)
) PARTITION BY HASH(user_id) PARTITIONS 8;
在这个例子中,即便user_id分布并不绝对均匀,哈希运算也会把数据比较均匀地分散到8个分区里。对于此类分区,查询时需要包含分区键的等值条件才能触发分区裁剪。如果查询只按click_time范围搜索,而没有user_id条件,优化器依然需要扫描所有分区。所以HASH分区的收益高度依赖业务方的查询模式是否固定。
还有一种线性HASH分区——LINEAR HASH,采用了更复杂的幂运算算法,在添加、删除或合并分区时,数据移动量比普通HASH小很多,但数据均匀性会略逊一筹。如果未来很可能频繁调整分区数量,线性HASH是更好的选择。
KEY分区:借助MySQL内置哈希
KEY分区和HASH类似,区别在于哈希运算由MySQL服务端内部实现,用户不需要指定表达式,只需列出参与分区的列,甚至可以完全不写列名,此时MySQL会使用主键或唯一键作为分区键。语法上使用PARTITION BY KEY(col_list) PARTITIONS n;。
CREATE TABLE session_store (
sid VARCHAR(128) NOT NULL,
data TEXT,
updated_at TIMESTAMP,
PRIMARY KEY (sid)
) PARTITION BY KEY() PARTITIONS 4;
这段定义没有写分区键,MySQL会自动选择主键sid进行分区。KEY分区也可以显式指定列,但指定的列必须包含在主键或唯一索引中,这是由分区规则强制的。KEY分区的一个实用场景是分布式环境下的数据分片雏形,虽然MySQL单机分区并不跨实例,但配合中间件可以形成不错的分库分表对应关系。
分区表维护中容易踩的坑
第一个坑是分区键与唯一索引的冲突。很多开发者习惯用自增ID做主键,再加一个业务唯一键,比如订单表的order_no。如果想按created_at分区,就必须把created_at加入到主键和所有唯一键中,这就迫使你改变原本简洁的索引设计,甚至可能为了分区而增加不必要的复合索引。如果业务上实在无法接受修改唯一键,那就不应该对该表使用分区,而是考虑归档或分表等替代方案。
第二个坑是ALTER TABLE操作引发的元数据锁。执行ADD PARTITION或DROP PARTITION通常只需修改元数据,速度很快,但如果同时有长事务未提交,DDL操作会被阻塞,进而阻塞后续所有对该表的读写,就像一场小范围的“雪崩”。因此分区维护操作最好放在低峰期,并提前检查是否有长时间未结束的事务。
第三个坑是分区表与唯一约束检查的性能。有些操作,例如LOAD DATA或大批量INSERT,MySQL需要对每行检查是否违反唯一约束,而在分区表下,这一检查可能需要在多个分区中进行。尽管分区裁剪能帮助定位,但如果没有合理利用分区键作为查询条件,检查成本依然会成倍放大。
分区与索引的配合策略
分区不能替代索引,二者是互补关系。分区减少了需要扫描的数据范围,而索引进一步加速了在分区内部的查找。通常在分区键上建立的索引会被优化器以“分区索引”的方式利用,而在非分区键上建立的索引则被称为“本地索引”,只对所在分区有效。查询时如果条件中既包含分区键又包含其他索引列,优化器可以先做分区裁剪,再用索引快速定位行,达到双重过滤效果。
需要注意的一点是,如果你经常按user_id查询订单,而表又按created_at分区,那么user_id上的索引仍然只能在一个一个分区内起作用,查询还是需要扫描所有分区里的索引。这种情况下,可以考虑按user_id进行HASH分区,把同一个用户的所有订单集中在一个分区内,这样该用户的查询就只需要访问单个分区,索引效率会显著提升。由此可见,选择分区键时一定要围绕最频繁的查询模式来权衡。
实际场景:日志表的分区化管理
拿一个典型的网关访问日志表来说,每天产生约2000万行数据,保留最近30天,历史数据不再需要。如果不分区,每天凌晨跑DELETE FROM access_log WHERE log_date < now() - interval 30 day会锁定大量行,产生巨大的undo日志,还可能影响线上查询。改用RANGE分区,每天一个分区,第31天直接将最老的分区DROP PARTITION,操作瞬间完成,几乎没有性能波动。
创建语句可以这样写:
CREATE TABLE access_log (
id BIGINT NOT NULL AUTO_INCREMENT,
uri VARCHAR(1024),
status_code SMALLINT,
response_time INT,
log_time DATETIME NOT NULL,
PRIMARY KEY (id, log_time)
) PARTITION BY RANGE(TO_DAYS(log_time)) (
PARTITION p20240222 VALUES LESS THAN (TO_DAYS('2024-02-23')),
PARTITION p20240223 VALUES LESS THAN (TO_DAYS('2024-02-24')),
-- ... 以此类推,尾部使用MAXVALUE
PARTITION p_future VALUES LESS THAN MAXVALUE
);
配合一个简单的事件调度器或外部脚本,每天凌晨执行两次操作:删除第31天前的分区,然后重组MAXVALUE分区增加今天的新分区。整个过程对应用无感知,查询最近某天的数据直接走单分区,性能稳定。
分区表并不是一个独立的高级特性,而是融合了存储、索引和优化器协作的系统工程。理解其原理并掌握正确的使用姿势,才能在数据量持续增长的背景下,牢牢把控数据库的响应时间。