一张 5 亿行的表按月分区后,清理历史数据从几小时变成了几秒

十五年数据库相关经验,做过 DBA、架构师、技术顾问。不求"颠覆",只求"靠谱"。


一张表到了几千万甚至上亿行,即使有索引,查询也开始变慢。插入变慢、更新变慢、DDL 操作几乎不可能。

很多人第一反应是"分库分表"。但分库分表不是第一步。在分库分表之前,有一个更轻量的方案:分区表。

分区表不需要中间件,不需要改应用代码,数据库原生支持。一张 5 亿行的表,按月分区后,每个分区只有几千万行,查询只扫目标分区,性能大幅提升。DDL 操作(比如删除过期数据)变成 drop 一个分区,秒级完成。

今天把分区表的完整实战讲清楚。什么时候该分区、范围/列表/hash 分区怎么选、怎么建、怎么维护、踩了什么坑。


01 什么时候该用分区表

不是所有大表都需要分区。分区表有明确的适用场景。

适合分区的场景

场景一:数据按时间持续增长,查询通常带时间条件。

日志表、订单表、流水表------数据按天/月增长,查询通常是"查某个月的数据"。这种表按时间范围分区,查询自动只扫目标分区(分区裁剪),性能提升明显。

场景二:需要定期清理历史数据。

保留近 12 个月数据,更早的删除。如果没有分区,DELETE 几千万行数据要很久,还会产生大量 WAL 日志和碎片。有了分区,直接 DROP 一个分区,秒级完成。

场景三:大表 DDL 操作困难。

给一张 3 亿行的表加索引,可能要跑几个小时,期间锁表。分区后,每个分区单独建索引,可以并行操作,不影响其他分区。

不适合分区的场景

场景一:数据量不大。 几百万行的表,分区只会增加复杂度,不会提升性能。

场景二:查询条件不包含分区键。 如果大部分查询都不带分区键(比如按时间分区的表,查询却按用户 ID),分区裁剪无法生效,反而多了一层管理开销。

场景三:跨分区关联查询多。 两个分区表 JOIN,如果分区键不一致,会产生跨分区笛卡尔积,性能反而下降。


02 三种分区策略:怎么选

分区策略的核心是"数据怎么分"。分错了,性能不升反降。

范围分区(Range Partitioning)

按某个列的值的范围分配数据。最常见的是按时间范围分区。

sql 复制代码
-- 按月份范围分区
CREATE TABLE orders (
    id BIGINT,
    user_id INT,
    amount DECIMAL(10,2),
    order_date DATE,
    status VARCHAR(20)
) PARTITION BY RANGE (order_date);

-- 创建分区
CREATE TABLE orders_2025_01 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE orders_2025_02 PARTITION OF orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
CREATE TABLE orders_2025_03 PARTITION OF orders
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

查询带 order_date 条件时,优化器自动只扫目标分区:

sql 复制代码
-- 只扫 orders_2025_02 一个分区
EXPLAIN SELECT * FROM orders WHERE order_date = '2025-02-15';

适用场景:时间序列数据、日志、订单、流水------查询条件通常带时间范围。

列表分区(List Partitioning)

按某个列的离散值分配数据。

sql 复制代码
-- 按地区列表分区
CREATE TABLE users (
    id BIGINT,
    name VARCHAR(100),
    region VARCHAR(20),
    created_at TIMESTAMP
) PARTITION BY LIST (region);

CREATE TABLE users_east PARTITION OF users
    FOR VALUES IN ('北京', '上海', '广州', '深圳');
CREATE TABLE users_central PARTITION OF users
    FOR VALUES IN ('武汉', '长沙', '郑州', '合肥');
CREATE TABLE users_west PARTITION OF users
    FOR VALUES IN ('成都', '重庆', '西安', '昆明');

适用场景:数据按地区、类型、状态等离散值分布,查询条件通常包含这些字段。

Hash 分区

对分区键做哈希运算,均匀分布到各个分区。

sql 复制代码
-- 按 user_id 做 hash 分区,分成 4 个分区
CREATE TABLE events (
    id BIGINT,
    user_id INT,
    event_type VARCHAR(50),
    created_at TIMESTAMP
) PARTITION BY HASH (user_id);

CREATE TABLE events_0 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_1 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_2 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_3 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 3);

user_id = 100 会落到哪个分区,由 100 % 4 决定。数据均匀分布,没有热点。

适用场景:没有明显范围或分类特征的大表,目的是分散数据量,让每个分区大小可控。

踩坑提醒:不要为了"看起来高级"选 hash 分区。大多数业务场景更适合范围分区或列表分区,因为查询条件通常带范围或分类。hash 分区适合没有查询模式特征的场景。


03 分区维护:创建、删除、拆分

分区表建好了不是"一劳永逸"。分区需要持续维护。

新增分区

范围分区最常见的维护操作:提前创建未来时间的分区。

sql 复制代码
-- 提前创建下个月的分区
CREATE TABLE orders_2025_04 PARTITION OF orders
    FOR VALUES FROM ('2025-04-01') TO ('2025-05-01');

建议在分区过期前至少提前一个月创建。 比如 3 月底要创建 4 月的分区,不要等到 4 月 1 号才建。如果写入数据时发现分区不存在,会直接报错。

PostgreSQL 支持默认分区(DEFAULT partition),当数据不匹配任何已有分区时落入默认分区。但默认分区不是好的长期方案------它会逐渐变大,而且不能直接拆分成独立分区(需要重建)。所以还是提前建好。

删除过期分区

sql 复制代码
-- 删除 12 个月前的分区
ALTER TABLE orders DETACH PARTITION orders_2024_01;
DROP TABLE orders_2024_01;

用 DETACH 先解绑,再 DROP。DETACH 是 DDL 操作,很快。DROP 删除实际数据,也很快(比 DELETE 快几个数量级)。

踩坑提醒:DROP 之前先确认分区里的数据不再需要。可以用 pg_dump 导出做归档,或者直接 COPY 到归档表。

分区拆分

如果一个分区数据量太大,可以拆分成更小的分区。但 PostgreSQL/KES 不支持直接 SPLIT 分区,需要手动操作:

sql 复制代码
-- 假设 orders_2025_02 数据量太大,想按周拆分
-- 1. 创建新的周分区
CREATE TABLE orders_2025_02_w1 PARTITION OF orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-02-08');
-- ... 创建 w2, w3, w4

-- 2. 先把月分区从主表解绑
ALTER TABLE orders DETACH PARTITION orders_2025_02;

-- 3. 把数据迁移到周分区
INSERT INTO orders SELECT * FROM orders_2025_02;

-- 4. 删除旧的月分区
DROP TABLE orders_2025_02;

这个过程需要停机窗口,因为中间有一段时间数据不在分区结构里。如果数据量大,用 pg_dump + pg_restore 或者逻辑导出导入。


04 分区表的常见坑

坑一:分区键选择不当

选了分区键,但大部分查询条件不带分区键。分区裁剪无法生效,查询扫了所有分区,性能没有提升反而下降。

怎么避免:先分析查询模式。80% 以上的查询是否都包含分区键?如果不是,换分区策略或者不分。

坑二:全局索引缺失

分区表的主键和唯一约束必须包含分区键。如果你的业务需要按非分区键做唯一校验,必须单独处理。

sql 复制代码
-- 主键必须包含分区键
CREATE TABLE orders (
    id BIGINT,
    order_date DATE,
    PRIMARY KEY (id, order_date)  -- 必须包含 order_date
) PARTITION BY RANGE (order_date);

如果 id 本身需要全局唯一(不含分区键),分区表不支持。这是分区表的固有限制。

坑三:跨分区 JOIN 性能差

两个分区表 JOIN,如果分区键不一致,会产生跨分区笛卡尔积。

sql 复制代码
-- orders 按 order_date 分区,order_items 按 item_date 分区
SELECT * FROM orders o JOIN order_items oi ON o.id = oi.order_id;
-- 如果 order_date 和 item_date 不一致,会扫所有分区组合

解法:关联的两个表用相同的分区键,或者把小表广播到所有分区(绑定集合策略)。

坑四:忘记创建未来分区

范围分区如果不提前创建未来分区,写入新数据时直接报错。

解法:用定时任务自动创建未来分区:

sql 复制代码
-- 每月 25 号创建下个月的分区
SELECT cron.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 分区适合无固定查询模式的大表。分区键选择必须基于实际查询模式,不是凭感觉。

分区表不是分库分表的替代品,是分库分表的前一步。能分区解决的不用分库分表,单机扛不住了再走分库分表的路径。

维护分区是持续工作:提前创建未来分区、定期清理过期分区、监控分区大小。建完就不管,分区表会变成定时炸弹。

后续我会继续分享主备切换与故障转移、锁排查这些话题,跟着我一篇篇学,数据库这块就没问题了。

有问题评论区见。


十五年数据库领域老炮。关注我,一起把数据库这件事搞明白。

相关推荐
hwscom1 小时前
几分钟开防护,SQL和XSS注入全被护卫神挡住
数据库·sql·xss
石小千1 小时前
Redis开启ACL认证
数据库·redis·缓存
东风微鸣2 小时前
把 Tailscale 接进 Prometheus 监控
java·数据库·prometheus
wno7042 小时前
MYSQL 常用合并函数
android·数据库·mysql
东风微鸣2 小时前
一次断电引发的血案:Ubuntu 26.04 升级中断后的完整救援实录
数据库·ubuntu·postgresql
大侠归来2 小时前
事务能回滚吗?Pipeline 算事务吗?
java·数据库·oracle
临床数据科学和人工智能兴趣组2 小时前
字符串转换为句子风格
大数据·数据库·人工智能·r语言·r语言-4.2.1
夕除2 小时前
PostgreSQL基础
数据库·oracle
挖掘狂人2 小时前
SQL 注入:一行代码如何撬开整个数据库
后端·sql·数据分析