十五年数据库相关经验,做过 DBA、架构师、技术顾问。不求"颠覆",只求"靠谱"。
一张表到了几千万甚至上亿行,即使有索引,查询也开始变慢。插入变慢、更新变慢、DDL 操作几乎不可能。
很多人第一反应是"分库分表"。但分库分表不是第一步。在分库分表之前,有一个更轻量的方案:分区表。
分区表不需要中间件,不需要改应用代码,数据库原生支持。一张 5 亿行的表,按月分区后,每个分区只有几千万行,查询只扫目标分区,性能大幅提升。DDL 操作(比如删除过期数据)变成 drop 一个分区,秒级完成。
今天把分区表的完整实战讲清楚。什么时候该分区、范围/列表/hash 分区怎么选、怎么建、怎么维护、踩了什么坑。
01 什么时候该用分区表
不是所有大表都需要分区。分区表有明确的适用场景。
适合分区的场景
场景一:数据按时间持续增长,查询通常带时间条件。
日志表、订单表、流水表——数据按天/月增长,查询通常是"查某个月的数据"。这种表按时间范围分区,查询自动只扫目标分区(分区裁剪),性能提升明显。
场景二:需要定期清理历史数据。
保留近 12 个月数据,更早的删除。如果没有分区,DELETE 几千万行数据要很久,还会产生大量 WAL 日志和碎片。有了分区,直接 DROP 一个分区,秒级完成。
场景三:大表 DDL 操作困难。
给一张 3 亿行的表加索引,可能要跑几个小时,期间锁表。分区后,每个分区单独建索引,可以并行操作,不影响其他分区。
不适合分区的场景
场景一:数据量不大。几百万行的表,分区只会增加复杂度,不会提升性能。
场景二:查询条件不包含分区键。如果大部分查询都不带分区键(比如按时间分区的表,查询却按用户 ID),分区裁剪无法生效,反而多了一层管理开销。
场景三:跨分区关联查询多。两个分区表 JOIN,如果分区键不一致,会产生跨分区笛卡尔积,性能反而下降。
02 三种分区策略:怎么选
分区策略的核心是"数据怎么分"。分错了,性能不升反降。
范围分区(Range Partitioning)
按某个列的值的范围分配数据。最常见的是按时间范围分区。
-- 按月份范围分区CREATETABLEorders(idBIGINT,user_idINT,amountDECIMAL(10,2),order_dateDATE,statusVARCHAR(20))PARTITIONBYRANGE(order_date);-- 创建分区CREATETABLEorders_2025_01PARTITIONOFordersFORVALUESFROM('2025-01-01')TO('2025-02-01');CREATETABLEorders_2025_02PARTITIONOFordersFORVALUESFROM('2025-02-01')TO('2025-03-01');CREATETABLEorders_2025_03PARTITIONOFordersFORVALUESFROM('2025-03-01')TO('2025-04-01');查询带order_date条件时,优化器自动只扫目标分区:
-- 只扫 orders_2025_02 一个分区EXPLAINSELECT*FROMordersWHEREorder_date='2025-02-15';适用场景:时间序列数据、日志、订单、流水——查询条件通常带时间范围。
列表分区(List Partitioning)
按某个列的离散值分配数据。
-- 按地区列表分区CREATETABLEusers(idBIGINT,nameVARCHAR(100),regionVARCHAR(20),created_atTIMESTAMP)PARTITIONBYLIST(region);CREATETABLEusers_eastPARTITIONOFusersFORVALUESIN('北京','上海','广州','深圳');CREATETABLEusers_centralPARTITIONOFusersFORVALUESIN('武汉','长沙','郑州','合肥');CREATETABLEusers_westPARTITIONOFusersFORVALUESIN('成都','重庆','西安','昆明');适用场景:数据按地区、类型、状态等离散值分布,查询条件通常包含这些字段。
Hash 分区
对分区键做哈希运算,均匀分布到各个分区。
-- 按 user_id 做 hash 分区,分成 4 个分区CREATETABLEevents(idBIGINT,user_idINT,event_typeVARCHAR(50),created_atTIMESTAMP)PARTITIONBYHASH(user_id);CREATETABLEevents_0PARTITIONOFeventsFORVALUESWITH(MODULUS4,REMAINDER0);CREATETABLEevents_1PARTITIONOFeventsFORVALUESWITH(MODULUS4,REMAINDER1);CREATETABLEevents_2PARTITIONOFeventsFORVALUESWITH(MODULUS4,REMAINDER2);CREATETABLEevents_3PARTITIONOFeventsFORVALUESWITH(MODULUS4,REMAINDER3);user_id = 100 会落到哪个分区,由100 % 4决定。数据均匀分布,没有热点。
适用场景:没有明显范围或分类特征的大表,目的是分散数据量,让每个分区大小可控。
踩坑提醒:不要为了"看起来高级"选 hash 分区。大多数业务场景更适合范围分区或列表分区,因为查询条件通常带范围或分类。hash 分区适合没有查询模式特征的场景。
03 分区维护:创建、删除、拆分
分区表建好了不是"一劳永逸"。分区需要持续维护。
新增分区
范围分区最常见的维护操作:提前创建未来时间的分区。
-- 提前创建下个月的分区CREATETABLEorders_2025_04PARTITIONOFordersFORVALUESFROM('2025-04-01')TO('2025-05-01');建议在分区过期前至少提前一个月创建。比如 3 月底要创建 4 月的分区,不要等到 4 月 1 号才建。如果写入数据时发现分区不存在,会直接报错。
PostgreSQL 支持默认分区(DEFAULT partition),当数据不匹配任何已有分区时落入默认分区。但默认分区不是好的长期方案——它会逐渐变大,而且不能直接拆分成独立分区(需要重建)。所以还是提前建好。
删除过期分区
-- 删除 12 个月前的分区ALTERTABLEorders DETACHPARTITIONorders_2024_01;DROPTABLEorders_2024_01;用 DETACH 先解绑,再 DROP。DETACH 是 DDL 操作,很快。DROP 删除实际数据,也很快(比 DELETE 快几个数量级)。
踩坑提醒:DROP 之前先确认分区里的数据不再需要。可以用 pg_dump 导出做归档,或者直接 COPY 到归档表。
分区拆分
如果一个分区数据量太大,可以拆分成更小的分区。但 PostgreSQL/KES 不支持直接 SPLIT 分区,需要手动操作:
-- 假设 orders_2025_02 数据量太大,想按周拆分-- 1. 创建新的周分区CREATETABLEorders_2025_02_w1PARTITIONOFordersFORVALUESFROM('2025-02-01')TO('2025-02-08');-- ... 创建 w2, w3, w4-- 2. 先把月分区从主表解绑ALTERTABLEorders DETACHPARTITIONorders_2025_02;-- 3. 把数据迁移到周分区INSERTINTOordersSELECT*FROMorders_2025_02;-- 4. 删除旧的月分区DROPTABLEorders_2025_02;这个过程需要停机窗口,因为中间有一段时间数据不在分区结构里。如果数据量大,用 pg_dump + pg_restore 或者逻辑导出导入。
04 分区表的常见坑
坑一:分区键选择不当
选了分区键,但大部分查询条件不带分区键。分区裁剪无法生效,查询扫了所有分区,性能没有提升反而下降。
怎么避免:先分析查询模式。80% 以上的查询是否都包含分区键?如果不是,换分区策略或者不分。
坑二:全局索引缺失
分区表的主键和唯一约束必须包含分区键。如果你的业务需要按非分区键做唯一校验,必须单独处理。
-- 主键必须包含分区键CREATETABLEorders(idBIGINT,order_dateDATE,PRIMARYKEY(id,order_date)-- 必须包含 order_date)PARTITIONBYRANGE(order_date);如果id本身需要全局唯一(不含分区键),分区表不支持。这是分区表的固有限制。
坑三:跨分区 JOIN 性能差
两个分区表 JOIN,如果分区键不一致,会产生跨分区笛卡尔积。
-- orders 按 order_date 分区,order_items 按 item_date 分区SELECT*FROMorders oJOINorder_items oiONo.id=oi.order_id;-- 如果 order_date 和 item_date 不一致,会扫所有分区组合解法:关联的两个表用相同的分区键,或者把小表广播到所有分区(绑定集合策略)。
坑四:忘记创建未来分区
范围分区如果不提前创建未来分区,写入新数据时直接报错。
解法:用定时任务自动创建未来分区:
-- 每月 25 号创建下个月的分区SELECTcron.schedule('create_next_month_partition','0 0 25 * *',$$-- 动态创建下个月分区(逻辑需按实际表结构调整)$$);或者用应用层脚本定期检查分区是否存在,不存在就创建。
对比:分区表 vs 分库分表 vs 单表优化
| 维度 | 单表优化 | 分区表 | 分库分表 |
|---|---|---|---|
| 数据量上限 | 几千万行 | 几亿行 | 理论无上限 |
| 查询性能 | 依赖索引 | 分区裁剪 + 索引 | 路由到目标分片 |
| 写入性能 | 单表写入 | 单分区写入 | 多分片并行写入 |
| 运维复杂度 | 低 | 中 | 高 |
| 应用改造 | 不需要 | 不需要 | 需要 |
| DDL 操作 | 慢(全表锁) | 快(分区级) | 分片级 |
| 历史数据清理 | DELETE(慢) | DROP 分区(快) | 清理分片 |
决策框架:按数据量和查询模式选择方案
| 数据量 | 查询模式 | 推荐方案 | 说明 |
|---|---|---|---|
| < 1000 万行 | 任意 | 单表 + 索引 | 分区只会增加复杂度 |
| 1000 万 - 1 亿行 | 查询带范围条件 | 范围分区 | 分区裁剪效果好 |
| 1000 万 - 1 亿行 | 查询带分类条件 | 列表分区 | 按分类路由 |
| 1000 万 - 1 亿行 | 查询无固定模式 | Hash 分区 | 均匀分散数据 |
| 1 亿 - 5 亿行 | 查询带范围条件 | 范围分区 + 二级索引 | 分区裁剪 + 索引 |
| > 5 亿行 | 单表扛不住 | 分库分表 | 单机到达极限 |
深度分析:为什么分区表经常被误用
分区表好用,但误用的情况很常见。
第一,为了分区而分区。数据量不大的时候就上了分区,以为"早晚用得上"。结果分区增加了管理复杂度,性能没有明显提升。分区是"数据量大到单表性能下降"时的方案,不是预防性方案。
第二,分区键和业务查询不匹配。按时间分区,但大部分查询按用户 ID 查。分区裁剪不生效,查一次扫所有分区,比不分还慢。分区键的选择必须基于实际查询模式,不是基于"哪个字段数据量大"。
第三,建了分区就不管了。分区表需要持续维护——提前创建未来分区、定期删除过期分区、监控分区大小。建完就不管,过几个月分区满了写入报错,或者过期数据堆积占满磁盘。
分区表的核心原则是:查询模式决定分区策略,数据量决定分区时机,持续维护决定分区效果。
总结
分区表是单机大数据量的最优解——不需要中间件、不需要改应用代码、数据库原生支持。
范围分区适合时间序列数据,列表分区适合分类数据,hash 分区适合无固定查询模式的大表。分区键选择必须基于实际查询模式,不是凭感觉。
分区表不是分库分表的替代品,是分库分表的前一步。能分区解决的不用分库分表,单机扛不住了再走分库分表的路径。
维护分区是持续工作:提前创建未来分区、定期清理过期分区、监控分区大小。建完就不管,分区表会变成定时炸弹。
后续我会继续分享主备切换与故障转移、锁排查这些话题,跟着我一篇篇学,数据库这块就没问题了。
有问题评论区见。
十五年数据库领域老炮。关注我,一起把数据库这件事搞明白。