1. 分区表到底解决了什么问题
1.1 分区表不是“优化一切”的银弹
我最早接触分区表,是因为线上有张日志表涨到了几千万行,每次按时间范围查数据都要扫半天,索引建了好几组也压不住。后面听人说“分区表能解决大表查询慢”,就直接把表按月份做了RANGE分区,结果线上业务该慢还是慢,甚至有些查询比之前还糟糕。后来踩了几次坑才明白,分区表要解决的核心问题其实是三个:
- 数据量的物理隔离,让查询能自动跳过无关数据。
- 过期数据的快速清理,让删除几十天的历史数据从“逐条DELETE”变成“秒删分区”。
- 数据写入路径的拆分,减少单一大文件对存储和缓冲池的竞争。
换句话说,分区表适合的是“查询条件明确、数据有生命周期、单表体量已经逼近存储或性能瓶颈”的场景。而不是说给一张几万行的小表套上分区,性能就能起飞。
1.2 分区表与索引的关系被很多人理解反了
分区表的一个关键点在于,它并不是“优化了单条SQL的执行效率”,而是让优化器在解析阶段就意识到“这个分区不用看”,从而减少扫描范围。理解这一点非常重要,因为如果查询条件不带分区键,MySQL就会老老实实把全部分区扫一遍,这时候分区表不但不会更快,反而可能比普通表更慢。
举个例子:一张表按order_date做了月分区,但是业务查询习惯是WHERE user_id = 12345 AND status = 1,完全没有时间条件。那MySQL只能遍历所有分区,每个分区里再用user_id索引去捞数据。分区多了以后,扫描的开销叠加起来,效果基本等同于全表扫描加多次索引查找。
所以我在引导新人设计分区表时,第一句话永远是:分区键必须是你业务查询里最高频、最稳定出现的那个条件,这个条件出现不了,分区就等于白分。
1.3 分区表适合谁、不适合谁
适合用分区表的用户,通常是下面这类场景:
- 订单、流水、日志类数据,有明确的时间范围,业务上经常按时间段做统计和清理。
- 数据量已经达到几千万甚至上亿,单表文件很大,备份、恢复、夜维都要花很久。
- 数据有生命周期,比如只保留90天或12个月,需要定期删除旧数据。
不适合的情况也很明显:
- 单表数据量只有几十万行,普通索引完全能扛住,分区属于徒增复杂度。
- 查询条件不固定,经常出现不带分区键的跨分区检索。
- 业务需要频繁更新分区键字段,而MySQL又不允许更新到其他分区时直接报错或产生非预期行为。
我见过有人把所有大表都做了分区,最后运维复杂度直线上升,连ALTER TABLE加个字段都要重建所有分区,耗时翻倍。分区表是个针对性手段,不是规范化标配。
2. 分区表的类型与分区键设计
2.1 四种常用分区类型怎么选
MySQL分区表天然支持RANGE、LIST、HASH、KEY四类,另外还有复合分区。每一种的适用场景完全不同。
RANGE分区是最常用的,它按连续区间把数据切到不同分区,适合时间、ID区间这类有顺序的字段。LIST分区则是按离散的值列表匹配分区,比如按省份、城市、业务线来分,每个枚举值对应一个分区。HASH分区是按分区键做哈希运算后取模,适合没有明显范围特征、但希望数据均匀分布的字段,比如用户ID。KEY分区和HASH类似,区别在于它使用MySQL内部函数对字段做哈希,可以不使用整数类型,比如字符串。
我在实际项目里选型一般看业务形态:
- 报表、日志类数据,选RANGE按时间分区。
- 多租户、多区域类数据,选LIST按租户ID或城市ID分区。
- 用户表、订单表,如果查询条件经常是精确的用户ID,选HASH按user_id分区。
- 只要分布均匀、没有范围查询需求,也可以用KEY分区按字符串字段拆分。
2.2 RANGE分区实战:从建表语句看细节
以一张订单流水表为例,按月做RANGE分区,建表语句通常这样写:
CREATE TABLE orders ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL, order_date DATE NOT NULL, PRIMARY KEY (id, order_date) ) PARTITION BY RANGE (TO_DAYS(order_date)) ( PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')), PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')), PARTITION p202503 VALUES LESS THAN (TO_DAYS('2025-04-01')), PARTITION p_future VALUES LESS THAN MAXVALUE );这里面有几个非常容易踩的坑。
第一个坑是主键问题。MySQL要求分区表的主键和唯一键必须包含分区键。所以上面我把order_date加进了主键,组成联合主键。如果你不想改主键结构,就只能用普通索引而非主键。
第二个坑是TO_DAYS和LESS THAN MAXVALUE。时间字段做RANGE分区时,通常转换成TO_DAYS()的天数来比较,因为DATE类型内部的直接比较也可以,但TO_DAYS更直观。最后一个分区必须用MAXVALUE来承接超出边界的数据,否则后续写入无法匹配分区时会直接报错。
第三个坑是未来数据的分区预留。如果不加MAXVALUE,当数据超过最后一个分区上限时,系统会报INSERT failed的错误。我看过很多线上事故,都是因为忘了加这个兜底分区,或者习惯性用MAXVALUE兜底,导致未来分区没法做自动压缩清理,最后不得不重新组织分区。
2.3 LIST、HASH、KEY分区的实用写法
LIST分区最常见的应用是按城市或业务分区。比如一个物流表按城市ID分区:
CREATE TABLE logistics ( id BIGINT NOT NULL, city_id INT NOT NULL, cargo_info VARCHAR(255), create_time DATETIME, PRIMARY KEY (id, city_id) ) PARTITION BY LIST (city_id) ( PARTITION p_city_1 VALUES IN (1, 2, 3), PARTITION p_city_2 VALUES IN (4, 5, 6), PARTITION p_city_other VALUES IN (7, 8, 9, 10) );LIST分区最大的坑是枚举值不全。如果插入的city_id不在任何分区的VALUES IN列表里,MySQL会直接拒绝写入。所以一般情况下,LIST分区要么把可能的值全部罗列完整,要么预留一个包含所有其他值的分区,但MySQL的LIST分区不支持DEFAULT兜底,只能把已知的全部列进去。这点和Oracle有些差异,很多从Oracle转过来的朋友第一次用会在这里踩坑。
HASH分区比较适合按用户ID取模。比如分成8个分区:
CREATE TABLE user_login_log ( id BIGINT NOT NULL, user_id BIGINT NOT NULL, login_time DATETIME NOT NULL, ip VARCHAR(64), PRIMARY KEY (id, user_id) ) PARTITION BY HASH(user_id) PARTITIONS 8;HASH分区不需要指定具体分区范围,MySQL会对user_id做哈希后取模,把数据尽量均匀打散。KEY分区和HASH写起来很像,只是关键字换成KEY,并且可以支持字符串类型:
CREATE TABLE user_session ( id BIGINT NOT NULL, session_id VARCHAR(128) NOT NULL, user_id BIGINT, login_time DATETIME, PRIMARY KEY (id, session_id) ) PARTITION BY KEY(session_id) PARTITIONS 8;KEY分区的好处是不要求分区键必须是整数,字符串也能直接分,而且数据分布通常比简单的取模更均匀。
2.4 复合分区:能不用就先别用
复合分区是指在RANGE或LIST主分区的基础上,再做HASH或KEY子分区。比如先按年做RANGE主分区,再按月做RANGE子分区。听起来灵活,但实际维护成本非常高。子分区数量庞大的时候,每个DDL都会拖垮变更效率,而且MySQL对子分区的支持限制很多。
我的经验是,除非数据量已经大到单层分区无法收敛,否则尽量不要用复合分区。先用单层RANGE按月分,等某个月的数据单分区也达到亿级,再考虑是否做子分区。给未来留点可演进性,而不是一上来就把复杂度拉满。
3. 分区表实操:从建表到数据迁移
3.1 分区键设计的三条铁律
每次做分区表设计评审,我都会让团队拿三句话来对照检查。
第一,分区键必须是所有高频业务SQL的必经条件。如果某条SQL不带分区键,就要做好全分区扫描的心理准备,而不是指望优化器有魔法。
第二,分区键的选择要保证数据分布尽量均匀。按HASH分区时尤其重要,如果某个用户数据量特别大,会造成数据倾斜,某个分区文件明显比其他分区大,反而拖垮节点性能。
第三,分区键的字段尽量保持稳定,不要频繁更新。MySQL官方对分区键更新有限制,更新分区键可能导致行移动到其他分区,如果跨分区更新,代价相当高。
3.2 分区表迁移流程:旧表数据搬到新表
常见迁移方式有两种。第一种是新建分区表,然后用INSERT INTO SELECT把旧表数据按分区条件搬过去,搬完再改表名。第二种是使用ALTER TABLE ... PARTITION BY直接重建分区,但大表执行这个操作会长时间锁表,生产环境一般不敢直接干。
我之前做过一次订单表的迁移,流程大概是这样的:
- 先创建一张结构完全相同的临时表
orders_new,建表时带上分区定义。 - 用
INSERT INTO orders_new SELECT * FROM orders分批导入数据。如果数据量特别大,要加条件分批跑,比如按order_date分段循环跑,避免事务日志暴涨。 - 所有数据核对完成后,执行
RENAME TABLE orders TO orders_old, orders_new TO orders。 - 观察一段时间确认无误后,再删除
orders_old表。 - 如果业务不能容忍停机,就用pt-online-schema-change或自研脚本分批同步增量数据,这种方案更稳妥。
这里有一个容易忽略的点:INSERT INTO SELECT导入数据时,MySQL不会自动校验每条数据应该落到哪个分区,而是根据分区键自动路由。如果旧表里有脏数据,比如NULL分区键,它会直接分到某个默认分区或者报错,这点要提前清洗。
3.3 分区表加索引的注意事项
分区表上的索引,从InnoDB存储引擎角度来看,是每个分区独立维护B+树的。普通索引在分区表和普通表上的语法差不多,但有两个细节要特别注意。
第一个细节是,如果表上有主键或唯一索引,分区键必须包含在其中。前面已经提过,这里再强调一次,因为几乎每个新人都在这上面卡过。
第二个细节是,索引的效果和分区是叠加产生的。如果你建了(order_date, user_id)联合索引,同时表按order_date分区,那查询带这两个字段时会有双重过滤:分区裁剪先去掉不需要的分区,索引再去定位具体行。效果最好。如果索引不含分区键,MySQL也能完成分区内检索,但优化效果会打折扣。
我在实践中通常会为分区表建两类索引:
- 一类是“分区键+高频查询列”的联合索引,用来服务带分区键的业务查询。
- 一类是“高频过滤条件列”的单列或联合索引,用来缓解不带分区键时的搜索压力。
3.4 分区表的DDL操作要留足时间
给分区表加一个普通字段,看起来只是加一列,但底层可能要对所有分区文件做结构变更。数据量大了以后,这个操作可能耗时几分钟甚至更久。我经历过一次给亿级分区表加字段,结果线上直接锁表,只读业务被阻断,最后只能深夜紧急操作。
所以要给分区表做DDL,建议用pt-online-schema-change或gh-ost这类工具,避免直接ALTER。如果表内数据量确实不大,直接ALTER也可以,但要评估好时间窗口。另外,8.0版本引入了INSTANT算法,可以快速加列,但只支持部分操作,使用前要确认版本和约束条件。
4. 分区裁剪与查询优化的真相
4.1 理解Partition Pruning:MySQL到底裁剪了什么
分区裁剪(Partition Pruning)是分区表性能提升的核心机制。优化器在执行SQL前,会根据WHERE条件里分区键的范围或等值条件,把不需要访问的分区直接剔除掉。比如按order_date做了12个月分区,查询条件是1月和2月,那优化器只扫这两个分区。
用EXPLAIN就能看到实际扫描了哪些分区:
EXPLAIN SELECT * FROM orders WHERE order_date = '2025-02-15';在MySQL 5.7里,结果会输出partitions字段,显示具体用到哪个分区;在MySQL 8.0里,EXPLAIN FORMAT=TREE可以显示得更细。我排查慢查询时,第一步就是看SQL的分区键有没有被“识别”,如果partitions显示p_other全分区,那说明SQL写得有问题,或者分区键被函数包裹导致无法裁剪。
4.2 最容易导致分区裁剪失效的写法
分区裁剪失效的常见原因有三个。
第一个是分区键上套了函数,比如WHERE DATE(order_date) = '2025-02-15'。MySQL计算不出分区的直接范围,只能全分区扫描。应该改成WHERE order_date >= '2025-02-15' AND order_date < '2025-02-16'。
第二个是隐式类型转换。比如分区键是DATE类型,但查询条件传了个字符串,并且两边字符集或排序规则不匹配,也可能优化器拿不准,干脆扫全分区。
第三个是分区键参与了运算,比如WHERE order_date + INTERVAL 1 DAY > NOW(),同样无法裁剪。我们要尽量保证分区键列独立出现在比较符的一侧。
4.3 分区剪枝不是万能的:无分区键查询怎么兜底
如果业务确实存在不带分区键的查询,我的做法是给这类查询单独建索引,然后评估是否可接受。比如订单表按order_date分区,但客服系统经常按user_id查,那我就在user_id上建索引。虽然这种查询要穿遍所有分区,但每个分区都能用索引快速定位,整体响应时间也许还能接受。
如果查询频率很高,但是数据量已经大到全分区扫描超时,那就要重新审视分区键选型了。比如改成按user_id做HASH分区,牺牲掉时间范围查询的分区裁剪,换取用户查询的性能。这本质上是个取舍问题,没有标准答案,要结合业务里面哪个查询更多、更关键来做决定。
4.4 用真实案例看性能差异
我曾经处理过一张访问日志表,4个月数据量接近3亿行。原来的SQL是按access_time范围查某个接口的调用记录,普通表加索引后还是要扫几千万行。改成RANGE月度分区后,查询某个3天时间窗口的数据,只扫对应1个分区,扫描行数从几千万掉到几百万,查询耗时从7秒左右降到200毫秒内。
另一个反面案例是,有人在用户表上按注册时间做了RANGE分区,但核心业务查询是精确查手机号。由于不带注册时间条件,所有查询都在全分区扫索引,最后加了多少分区都没用,只能重建表改成按user_id做HASH分区,问题才解决。这两个案例说明分区键必须和查询条件强绑定,分区才能发挥价值。
5. 分区表的常见问题与排查技巧
5.1 分区键不能为NULL?那怎么办
MySQL对分区键NULL的处理比较特殊。RANGE分区会把NULL视为最小值,放到第一个分区;LIST分区只有在分区列表里包含NULL时才允许插入;HASH和KEY分区则把NULL视为0参与计算。
这会导致一个隐蔽问题:如果业务里分区键允许为NULL,数据会被悄悄堆到同一个分区里,造成该分区数据量膨胀、分布不均。建表时最好把分区键设为NOT NULL,如果业务必须允许空值,可以用COALESCE或填默认值来兜底,避免出现数据倾斜。
5.2 删除历史数据用什么姿势
分区表最大的运维红利就是快速清理历史数据。比如日志表按月分区,要删除两年前的数据,直接删分区即可:
ALTER TABLE access_log DROP PARTITION p202301;这个操作几乎是瞬间完成的,并且不产生大量binlog和undo日志。相比DELETE FROM access_log WHERE access_time < '2023-01-01',两者的性能差距是数量级的。删分区前要确认该分区没有需要保留的数据,因为DROP PARTITION会连带分区里的数据和索引一起删除,无法回滚。
如果不想删除物理数据,而只是归档,可以用ALTER TABLE ... REORGANIZE PARTITION或者ALTER TABLE ... EXCHANGE PARTITION把分区数据交换到另一张普通表。EXCHANGE PARTITION需要两个表结构完全一致,实操时要先建一张普通备份表,然后执行交换,再把备份表导出或者存到冷存储。
5.3 分区加太多会不会有副作用
分区数量不是越多越好。每个分区在InnoDB内部都是一个独立的表空间文件(或段),打开文件句柄、维护统计信息都有开销。分区太多时,优化器要处理的分区元数据也会变多,某些场景下甚至比普通表更慢。
我一般建议单表单层分区数量控制在50到100以内。按月份计算,3到5年的月度分区刚好在这个范围。超过这个数,考虑把旧数据归档走。HASH分区控制在8到16个左右,除非数据量极其巨大,否则没必要搞几百个分区。
5.4 MySQL 8.0对分区表有哪些增强
MySQL 8.0开始正式支持分区表的InnoDB原生特性,去掉了5.7时代不少限制。比如8.0支持分区表的部分ALTER操作使用INSTANT算法,加列场景下更快;查询优化器对分区裁剪的判断更聪明;也不再需要依赖早期的partition_management之类临时插件。
但8.0同时也移除了旧版本里的一些非标准语法,比如不再支持在分区表上使用某些特定语法参数。如果是从5.7升级,要先跑一遍CHECK TABLE FOR UPGRADE,看看分区定义有没有兼容性问题。我在5.7环境里建过的PARTITION BY RANGE COLUMNS表,在8.0里基本无感,但老旧的PARTITION BY LINEAR HASH某些用法在升级时会有警告,需要提前验证。
5.5 分区表慢查询排查工具和方法
排查分区表慢查询,我习惯按下面几步来:
- 用
EXPLAIN看分区裁剪是否生效,确认SQL实际扫描了多少分区。 - 用
SHOW TABLE STATUS看各分区的数据行数和数据长度,判断是否出现数据倾斜。 - 用
information_schema.PARTITIONS查询每个分区的大小和行数,快速定位超大分区。 - 用
performance_schema或慢查询日志,统计哪些SQL长时间遍历了全部分区。
如果真的发现某个分区异常大,可以进一步查该分区的数据分布,看看是不是业务热点集中导致。比如订单表按地区LIST分区时,某一线城市的数据量可能是其他地区的几十倍,这时候单独一个分区就可能成为瓶颈,需要使用HASH子分区或改成分片区+用户的复合策略。
6. 面试常见问题与我的经验总结
6.1 MySQL分区表高频面试题怎么答
在技术面里,分区表经常和分库分表、索引优化放一起考。核心问题就那么几个:
- 分区表的好处和坏处分别是什么?答:好处是管理方便、能快速清理数据、查询可裁剪;坏处是分区键限制严格、DDL成本高、跨分区查询可能更慢。
- 分区和分表的区别?答:分区是物理存储层面的拆分,业务无感知,还是在同一个MySQL实例里;分表是逻辑层的拆分,通常要配合中间件或应用层路由,可以把数据分布到不同实例。
- 分区键能加索引吗?答:可以,但主键和唯一键必须包含分区键,普通索引无此限制。
- 分区表一定能提升查询性能吗?答:不一定,只有SQL条件能触发分区裁剪时才明显;全分区扫描可能更慢。
- 分区表如何清理数据?答:DROP PARTITION秒级删除,比DELETE高效得多。
面试里最好带一个自己实操过的分区表案例,比如“我在做订单表重构时,按天分区后,慢查询从多少降到多少”。有数据、有对比、有结论,比背书效果好很多。
6.2 我踩过的分区键选取大坑
早年间我接过一个用户行为表,设计者把分区键选了event_type整数字段,用LIST分区按事件类型分了十几个分区。结果产品有个“创建事件”类型的数据量占90%以上,那个单分区直接飙到几千万行,查询依然很慢。后来把事件表改按event_time做RANGE分区,再叠加event_type索引,数据分布和查询效率才平衡。
还有一个反面案例,为了迁就某个统计SQL,把订单表按城市做LIST分区,但订单量最大的城市集中了太多数据,单分区还是太大。最后改成了按城市和月份做复合分区。这类问题说明一点:分区键不能只看业务逻辑,还要看真实数据分布,设计前最好先跑一段SELECT 分区键, COUNT(*) GROUP BY 分区键看分布情况。
6.3 分区表后续扩展思路
如果你已经跑了一版分区表,后续想继续优化,可以从几个方向着手:
- 合并小分区,减少分区数量,降低DDL和元数据开销。
- 使用
ALTER TABLE ... REORGANIZE PARTITION调整分区边界,适应数据增长。 - 结合归档表,把历史分区定期搬运到归档实例或冷存储。
- 如果单实例还是扛不住,再考虑从分区表走向分库分表,或者引入列存引擎做分析查询。
这些扩展一定要建立在监控数据之上,不要拍脑袋加分区。先看慢查询、分区大小、磁盘占用,再决定下一步怎么走。
最后再分享一个小细节:分区表设计时,尽量把分区定义和清理策略一起交付给运维,比如每个月自动新增下月分区、自动清理N个月前分区的定时脚本。很多分区表上线后没人维护,几个月后新数据因为分区边界没覆盖而写入失败,这种事故非常低级,但非常常见。如果你正在规划分区表,请一定把分区维护流程纳入日常工作,而不是建完就扔。