PostgreSQL 分区表
分区(Partitioning)是将大表分割成多个物理上独立但逻辑上统一的小表的技术。对于大型数据库,分区可以显著提高查询性能和管理效率。
分区概述
什么是分区?
分区将一个大表按照特定规则拆分成多个较小的物理表(分区),但对用户而言仍然是一个逻辑上的整体表。
分区的优势
| 优势 | 说明 |
|---|---|
| 查询性能 | 分区裁剪减少扫描数据量 |
| 维护效率 | 可以单独维护、删除分区 |
| 索引优化 | 每个分区独立索引,重建更快 |
| 数据加载 | 批量加载可以挂载新分区 |
| 存储管理 | 不同分区可放在不同存储 |
何时使用分区?
- 表数据量大:超过几百 GB 或几亿行
- 有明显的分区键:如日期、地区、类型等
- 查询有分区条件:经常按分区键过滤
- 需要定期归档:如按月删除旧数据
分区类型
范围分区(RANGE)
按值的范围分区,最常用于日期字段:
-- 创建分区表
CREATE TABLE orders (
id BIGSERIAL,
customer_id INTEGER,
order_date DATE NOT NULL,
total_amount NUMERIC(10, 2),
status VARCHAR(20)
) PARTITION BY RANGE (order_date);
-- 创建分区
CREATE TABLE orders_2024_q1 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE orders_2024_q2 PARTITION OF orders
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
CREATE TABLE orders_2024_q3 PARTITION OF orders
FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');
CREATE TABLE orders_2024_q4 PARTITION OF orders
FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');
-- 创建默认分区(捕获不属于任何分区的数据)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
列表分区(LIST)
按离散值列表分区,适合地区、类型等:
-- 创建列表分区表
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
region VARCHAR(50),
email VARCHAR(100)
) PARTITION BY LIST (region);
-- 创建分区
CREATE TABLE customers_north PARTITION OF customers
FOR VALUES IN ('北京', '天津', '河北');
CREATE TABLE customers_south PARTITION OF customers
FOR VALUES IN ('广东', '福建', '海南');
CREATE TABLE customers_east PARTITION OF customers
FOR VALUES IN ('上海', '江苏', '浙江');
CREATE TABLE customers_west PARTITION OF customers
FOR VALUES IN ('四川', '重庆', '云南');
CREATE TABLE customers_other PARTITION OF customers DEFAULT;
哈希分区(HASH)
按哈希值均匀分布数据:
-- 创建哈希分区表
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT,
event_type VARCHAR(50),
event_data JSONB,
created_at TIMESTAMP DEFAULT NOW()
) PARTITION BY HASH (user_id);
-- 创建分区(通常创建 2 的幂次方个分区)
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);
多列分区
可以基于多个列进行分区:
-- 按年和月组合分区
CREATE TABLE sales (
id BIGSERIAL,
sale_date DATE,
region VARCHAR(50),
amount NUMERIC(12, 2)
) PARTITION BY RANGE (sale_date, region);
-- 这种方式较少使用,通常建议使用复合分区
复合分区
PostgreSQL 10+ 支持分区嵌套(子分区):
-- 先按日期分区,再按地区子分区
CREATE TABLE sales (
id BIGSERIAL,
sale_date DATE NOT NULL,
region VARCHAR(50),
amount NUMERIC(12, 2)
) PARTITION BY RANGE (sale_date);
-- 创建父分区
CREATE TABLE sales_2024 PARTITION OF sales
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')
PARTITION BY LIST (region);
-- 创建子分区
CREATE TABLE sales_2024_north PARTITION OF sales_2024
FOR VALUES IN ('北京', '天津');
CREATE TABLE sales_2024_south PARTITION OF sales_2024
FOR VALUES IN ('广东', '福建');
CREATE TABLE sales_2024_other PARTITION OF sales_2024 DEFAULT;
分区索引
全局索引 vs 本地索引
PostgreSQL 的分区索引是"本地索引"——每个分区有独立的索引:
-- 在分区表上创建索引(自动在所有分区创建)
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_date ON orders(order_date);
-- 创建主键(必须包含分区键)
ALTER TABLE orders ADD PRIMARY KEY (id, order_date);
-- 创建唯一约束(必须包含分区键)
ALTER TABLE orders ADD CONSTRAINT uk_orders_customer_date
UNIQUE (customer_id, order_date);
仅在特定分区创建索引
-- 在特定分区创建索引
CREATE INDEX idx_orders_2024_q1_status ON orders_2024_q1(status);
分区管理
添加分区
-- 添加新的范围分区
CREATE TABLE orders_2025_q1 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
-- 添加新的列表分区
ALTER TABLE customers ADD PARTITION customers_central
FOR VALUES IN ('河南', '湖北');
删除分区
-- 删除分区(数据也会删除)
DROP TABLE orders_2022_q1;
-- 从分区表分离分区(保留数据)
ALTER TABLE orders DETACH PARTITION orders_2022_q1;
-- 并发分离(PostgreSQL 14+)
ALTER TABLE orders DETACH PARTITION orders_2022_q1 CONCURRENTLY;
合并分区
-- 分离旧分区
ALTER TABLE orders DETACH PARTITION orders_2024_q1;
-- 重命名
ALTER TABLE orders_2024_q1 RENAME TO orders_archive_q1;
-- 创建新的合并分区
CREATE TABLE orders_2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
-- 将数据移动到新分区
INSERT INTO orders SELECT * FROM orders_archive_q1;
-- 删除旧表
DROP TABLE orders_archive_q1;
分区裁剪
分区裁剪是自动优化,查询时只扫描相关分区:
-- 启用分区裁剪(默认启用)
SET enable_partition_pruning = on;
-- 查看是否使用分区裁剪
EXPLAIN ANALYZE SELECT * FROM orders
WHERE order_date = '2024-02-15';
-- 输出中会显示:Partition Iterator
-- 和:Partitions selected: 1 (out of 4)
查看分区信息
-- 查看分区表结构
\d+ orders
-- 查询分区信息
SELECT
parent.relname AS parent_table,
child.relname AS partition_name,
pg_get_expr(child.relpartbound, child.oid) AS partition_expression
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;
-- 使用系统视图
SELECT * FROM pg_partitions WHERE tablename = 'orders';
分区与约束
分区键约束
分区键必须有 NOT NULL 约束:
-- 正确:分区键有 NOT NULL
CREATE TABLE orders (
id BIGSERIAL,
order_date DATE NOT NULL, -- 必须有 NOT NULL
customer_id INTEGER
) PARTITION BY RANGE (order_date);
-- 错误:分区键可以为 NULL
CREATE TABLE bad_orders (
id BIGSERIAL,
order_date DATE, -- 缺少 NOT NULL
customer_id INTEGER
) PARTITION BY RANGE (order_date);
-- ERROR: cannot create range partition with NULL partition key
添加约束
-- 添加 CHECK 约束(自动应用到所有分区)
ALTER TABLE orders ADD CONSTRAINT chk_amount_positive
CHECK (total_amount >= 0);
-- 添加外键(分区表可以有外键引用其他表)
ALTER TABLE orders ADD CONSTRAINT fk_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
-- 注意:分区表不能被其他表引用
分区表数据操作
插入数据
-- 插入数据自动路由到正确分区
INSERT INTO orders (customer_id, order_date, total_amount, status)
VALUES (1, '2024-02-15', 100.00, 'completed');
-- 插入到不存在的分区会报错(除非有默认分区)
INSERT INTO orders (customer_id, order_date, total_amount, status)
VALUES (1, '2025-06-15', 100.00, 'completed');
-- ERROR: no partition of relation "orders" found for row
批量加载
-- 方法1:临时挂载分区
-- 创建新表(结构与分区表相同)
CREATE TABLE orders_new (LIKE orders INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
-- 批量加载数据
\copy orders_new FROM 'data.csv' CSV
-- 将新表作为分区挂载
ALTER TABLE orders ATTACH PARTITION orders_new
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
-- 方法2:直接插入分区
INSERT INTO orders_2024_q1 SELECT * FROM staging_table;
更新和删除
-- 更新分区键会导致行移动
UPDATE orders
SET order_date = '2024-05-01'
WHERE id = 1;
-- 行会从 q1 分区移动到 q2 分区
-- 删除旧数据(直接删除分区)
DROP TABLE orders_2020_q1;
-- 比DELETE快得多,且不会产生死元组
分区维护任务
自动创建分区
使用函数和触发器自动创建分区:
-- 创建自动分区函数
CREATE OR REPLACE FUNCTION create_monthly_partition()
RETURNS VOID AS $$
DECLARE
partition_date DATE;
partition_name TEXT;
start_date TEXT;
end_date TEXT;
BEGIN
-- 计算下个月的分区
partition_date := DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month');
partition_name := 'orders_' || TO_CHAR(partition_date, 'YYYY_MM');
start_date := partition_date::TEXT;
end_date := (partition_date + INTERVAL '1 month')::TEXT;
-- 检查分区是否存在
IF NOT EXISTS (
SELECT 1 FROM pg_class WHERE relname = partition_name
) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF orders FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
RAISE NOTICE 'Created partition: %', partition_name;
END IF;
END;
$$ LANGUAGE plpgsql;
-- 手动执行
SELECT create_monthly_partition();
-- 或使用 pg_cron 扩展定期执行
-- SELECT cron.schedule('create_partition', '0 0 1 * *',
-- $$SELECT create_monthly_partition()$$);
自动删除旧分区
-- 创建删除旧分区的函数
CREATE OR REPLACE FUNCTION drop_old_partitions(
table_name TEXT,
keep_months INTEGER DEFAULT 12
)
RETURNS VOID AS $$
DECLARE
partition_rec RECORD;
cutoff_date DATE;
BEGIN
cutoff_date := DATE_TRUNC('month', CURRENT_DATE - (keep_months || ' months')::INTERVAL);
FOR partition_rec IN
SELECT
child.relname AS partition_name,
pg_get_expr(child.relpartbound, child.oid) AS partition_bound
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = table_name
LOOP
-- 简化版:实际需要解析 partition_bound
RAISE NOTICE 'Checking partition: %', partition_rec.partition_name;
END LOOP;
END;
$$ LANGUAGE plpgsql;
分区最佳实践
分区键选择
-- 好的分区键选择:
-- 1. 经常用于 WHERE 条件
-- 2. 数据分布均匀
-- 3. 有明确的业务含义
-- 示例1:按时间分区(适合日志、订单等)
PARTITION BY RANGE (created_at);
-- 示例2:按地区分区(适合多地区业务)
PARTITION BY LIST (region);
-- 示例3:按用户ID哈希(适合高并发写入)
PARTITION BY HASH (user_id);
分区粒度
-- 分区数量建议:
-- 太少:分区优势不明显
-- 太多:管理开销大
-- 建议:
-- - 范围分区:每个分区 1GB - 100GB
-- - 分区总数:不超过几百个
-- 对于时间序列数据:
-- - 高频写入:按日或周分区
-- - 中频写入:按月分区
-- - 低频查询:按季度或年分区
分区与索引
-- 索引建议:
-- 1. 每个分区独立索引
-- 2. 分区键通常不需要额外索引
-- 3. 高频查询列创建索引
-- 创建索引
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
-- 分区键 order_date 不需要索引,分区裁剪已经优化
分区表限制
-- 1. 主键和唯一约束必须包含分区键
-- 错误:
ALTER TABLE orders ADD PRIMARY KEY (id);
-- ERROR: primary key constraints are not supported on partitioned tables
-- 正确:
ALTER TABLE orders ADD PRIMARY KEY (id, order_date);
-- 2. 不支持 AFTER ROW 触发器在分区表上
-- 需要在各个分区上单独创建触发器
-- 3. 外键约束限制
-- 分区表可以引用普通表
-- 普通表不能引用分区表(PostgreSQL 11 及之前)
-- 4. ON CONFLICT 限制
-- PostgreSQL 11+ 支持,但需要分区键在冲突目标中
小结
本章我们学习了 PostgreSQL 分区表:
- 分区概念:将大表拆分成多个物理小表
- 分区类型:范围分区、列表分区、哈希分区
- 复合分区:分区嵌套
- 分区索引:每个分区独立索引
- 分区管理:添加、删除、合并分区
- 分区裁剪:自动优化查询
- 最佳实践:分区键选择、粒度控制
练习
- 创建一个按月的范围分区表
- 设计一个按地区的列表分区表
- 实现分区自动创建和删除脚本
- 测试分区裁剪效果