华为云openGauss分区表设计:亿级数据查询性能优化实战

举报
行者·全栈架构师 发表于 2026/07/04 13:45:19 2026/07/04
【摘要】 单表 2 亿行,全表扫描需要 30 秒,但业务要求订单查询 P99 < 50ms。我们通过分区表重构,将 orders 表按日期范围分区 + 按用户 ID 哈希二级分区,查询性能提升 40 倍。本文详细讲解了分区策略选择、分区裁剪验证、冷热数据分离、分区分区维护等核心实践,所有 SQL 和脚本可直接复用。

⏱ 预计阅读时间:15 分钟

🎯 背景:单表 2 亿行的痛

订单中心上线两年后,orders 表数据量突破了 2 亿行。当时的情况:

指标 数值
总行数 约 2.1 亿
日均增长 约 80 万行
数据保留 3 年
查询类型 按用户查询 + 按时间范围查询
P99 延迟 从 30ms 恶化为 480ms
全表扫描 30 秒+

最严重的问题:索引虽然建了,但索引本身太大了(主键索引约 12GB),缓存放不下,大量走索引扫描变成了"伪全表扫描"。

🏗️ 分区方案设计

分区策略选型

HWG-006-partition-table-billion-rows_diagram_1.png

最终方案范围分区(按天) + 哈希子分区(16 个)。先按时间范围做一级裁剪,再按用户哈希做二级裁剪。

建表语句

-- 创建分区表
CREATE TABLE orders_partitioned (
    id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    total_amount NUMERIC(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
    
    PRIMARY KEY (id, created_at, user_id)  -- 分区键必须包含在主键中
) PARTITION BY RANGE (created_at)
  SUBPARTITION BY HASH (user_id)
  SUBPARTITION TEMPLATE (
    SUBPARTITION p0 VALUES (0),
    SUBPARTITION p1 VALUES (1),
    SUBPARTITION p2 VALUES (2),
    SUBPARTITION p3 VALUES (3),
    SUBPARTITION p4 VALUES (4),
    SUBPARTITION p5 VALUES (5),
    SUBPARTITION p6 VALUES (6),
    SUBPARTITION p7 VALUES (7),
    SUBPARTITION p8 VALUES (8),
    SUBPARTITION p9 VALUES (9),
    SUBPARTITION p10 VALUES (10),
    SUBPARTITION p11 VALUES (11),
    SUBPARTITION p12 VALUES (12),
    SUBPARTITION p13 VALUES (13),
    SUBPARTITION p14 VALUES (14),
    SUBPARTITION p15 VALUES (15)
  )
(
    -- 按天创建分区,提前创建 60 天
    PARTITION p20250117 VALUES LESS THAN ('2025-01-18'),
    PARTITION p20250118 VALUES LESS THAN ('2025-01-19'),
    PARTITION p20250119 VALUES LESS THAN ('2025-01-20'),
    -- ... 后续分区通过脚本自动创建
    PARTITION p20250317 VALUES LESS THAN ('2025-03-18'),
    PARTITION p_max VALUES LESS THAN (MAXVALUE)  -- 兜底分区
);

分区键选择的约束

注意:openGauss 的分区表要求 主键必须包含所有分区键。这是一个常见的坑 —— 业务上 id 是唯一的,但由于分区键 created_atuser_id 也必须加入主键,主键变成了联合主键。

-- ❌ 这个主键不行(缺少分区键)
PRIMARY KEY (id)

-- ✅ 必须包含所有分区键
PRIMARY KEY (id, created_at, user_id)

-- 或者用唯一索引代替主键
CREATE UNIQUE INDEX idx_orders_id_unique ON orders_partitioned(id);

🔬 分区裁剪验证

分区的核心价值在于分区裁剪(Partition Pruning)—— 查询时只扫描相关分区,而不是全表。

HWG-006-partition-table-billion-rows_diagram_3.png

-- 验证分区裁剪是否生效
EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM orders_partitioned 
WHERE user_id = 12345 
  AND created_at >= '2025-03-01' 
  AND created_at < '2025-03-08';
-- 期望的执行计划(只扫描 1 天分区 × 1 个哈希子分区 = 1/32 的数据)
Append  (cost=0.00..12.34 rows=5 width=128)
  Subplans Removed: 30         -- 32 个子分区中跳过 30 个
  ->  Index Scan using orders_p20250301_p3_idx on orders_p20250301_p3
        Index Cond: (user_id = 12345)
        (actual time=0.012..0.034 rows=2 loops=1)

关键指标看 Subplans Removed—— 跳过的分区越多,裁剪越有效。

💥 踩坑:条件写法不对导致分区裁剪失效

-- ❌ 分区裁剪失效(函数包裹了分区键)
SELECT * FROM orders_partitioned 
WHERE DATE(created_at) = '2025-03-17';

-- ✅ 分区裁剪生效(直接使用分区键)
SELECT * FROM orders_partitioned 
WHERE created_at >= '2025-03-17' AND created_at < '2025-03-18';
-- ❌ 分区裁剪失效(隐式类型转换)
SELECT * FROM orders_partitioned 
WHERE created_at = '2025-03-17';  -- 字符串自动转 timestamp,但优化器不确定

-- ✅ 分区裁剪生效(显式类型)
SELECT * FROM orders_partitioned 
WHERE created_at = '2025-03-17'::timestamp;

🧊 冷热数据分离

订单数据有典型的冷热特征:3 个月内的数据被频繁访问(80% 的查询),3 个月前的数据极少访问

HWG-006-partition-table-billion-rows_diagram_4.png

分区移动 + 表空间分离

-- 创建冷数据表空间(慢速归档存储)
CREATE TABLESPACE ts_orders_cold 
OWNER omm 
LOCATION '/data/opengauss/cold_data';

-- 将 3 个月前的分区移动到冷表空间
ALTER TABLE orders_partitioned 
    MOVE PARTITION p20241017 TABLESPACE ts_orders_cold;
ALTER TABLE orders_partitioned 
    MOVE PARTITION p20241018 TABLESPACE ts_orders_cold;
-- ... 可以用脚本批量执行

冷热数据迁移脚本

#!/bin/bash
# move_cold_partitions.sh — 每月 1 号执行

PGHOST="localhost"
PGUSER="omm"
PGDB="order_db"
COLD_DATE=$(date -d "3 months ago" +%Y%m%d)

gsql -h $PGHOST -U $PGUSER -d $PGDB <<EOF
-- 查询所有分区
SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) 
FROM pg_stat_user_tables 
WHERE relname LIKE 'orders_partitioned_p%'
ORDER BY relname;

-- 批量移动 3 个月前的分区到冷表空间
DO \$\$
DECLARE
    part_name TEXT;
BEGIN
    FOR part_name IN 
        SELECT relname FROM pg_stat_user_tables 
        WHERE relname LIKE 'orders_partitioned_p%'
          AND SUBSTRING(relname FROM 'p(\d{8})')::INTEGER < $COLD_DATE
    LOOP
        EXECUTE format(
            'ALTER TABLE orders_partitioned MOVE PARTITION %s TABLESPACE ts_orders_cold;',
            part_name
        );
        RAISE NOTICE 'Moved % to cold storage', part_name;
    END LOOP;
END;
\$\$;
EOF

效果

指标 优化前 优化后
热数据存储 180GB(全量) 45GB(仅 3 个月)
热查询 P99 480ms 28ms
shared_buffers 命中率 89% 97%
备份时间 47 min 22 min

⏰ 分区自动管理

每天创建新分区,否则下个月的数据会全部落到 p_max 兜底分区,失去分区优势。

#!/bin/bash
# create_partitions.sh — 每天凌晨 1:00 通过 crontab 执行

PGHOST="localhost"
PGUSER="omm"
PGDB="order_db"

# 提前创建未来 60 天的分区
DAYS_AHEAD=60
TODAY=$(date +%Y%m%d)

gsql -h $PGHOST -U $PGUSER -d $PGDB <<EOF
DO \$\$
DECLARE
    d DATE;
    part_name TEXT;
    date_str TEXT;
    next_date_str TEXT;
    count_existing INT;
BEGIN
    FOR i IN 0..$DAYS_AHEAD LOOP
        d := CURRENT_DATE + i;
        date_str := TO_CHAR(d, 'YYYYMMDD');
        next_date_str := TO_CHAR(d + 1, 'YYYYMMDD');
        part_name := 'p' || date_str;
        
        -- 检查分区是否已存在
        SELECT COUNT(*) INTO count_existing
        FROM pg_partition
        WHERE parentid = 'orders_partitioned'::regclass
          AND relname = part_name;
        
        IF count_existing = 0 THEN
            EXECUTE format(
                'ALTER TABLE orders_partitioned SPLIT PARTITION p_max AT (
                    %L::timestamp
                ) INTO (
                    PARTITION %s,
                    PARTITION p_max
                );',
                (d + 1)::text,
                part_name
            );
            RAISE NOTICE 'Created partition: %', part_name;
        END IF;
    END LOOP;
END;
\$\$;
EOF

💥 踩坑:SPLIT PARTITION 对当前分区影响

第一次自动创建分区时,我们没想到 SPLIT PARTITION p_max 会短暂锁表。执行时正好赶上高峰期,业务侧几秒超时。

修复:把分区创建时间改到凌晨业务低谷期,并且一次性创建 60 天,不是每天只创建 1 个。

# crontab — 每天跑一次,但因为已存在的分区会被跳过,实际负载很低
0 1 * * * /opt/scripts/create_partitions.sh >> /var/log/partition.log 2>&1

📊 查询性能对比

-- 场景一:查询某用户最近 7 天的订单(最频繁的查询)
-- 非分区表
SELECT * FROM orders WHERE user_id = 12345 
  AND created_at >= now() - interval '7 days';
-- 耗时: ~1800ms (全表扫描)

-- 分区表(裁剪到 7 个分区 × 1 个子分区)
SELECT * FROM orders_partitioned WHERE user_id = 12345 
  AND created_at >= now() - interval '7 days';
-- 耗时: ~8ms
查询场景 非分区表 分区表 提升倍数
按用户查最近 7 天订单 1,800ms 8ms 225x
按时间范围查 1 个月数据 4,200ms 35ms 120x
按月统计订单量 12,000ms 180ms 67x
复杂聚合(用户 + 时间 + 状态) 8,500ms 120ms 70x
按 ID 精确查询(走主键) 0.8ms 0.9ms 0.9x(不变)

HWG-006-partition-table-billion-rows_diagram_5.png

🧭 分区方案选择决策树

根据数据量、查询模式和维护策略,可以参考以下决策流程来选择最合适的分区方案:

HWG-006-partition-table-billion-rows_diagram_2.png

💡 使用建议:先根据当前数据量 + 预估年增长率判断走哪条分支,再结合团队的运维自动化程度选择最终方案。混合查询场景下,范围-哈希复合分区是最推荐的一劳永逸方案,本文案例正是这种选型。

❓ 常见问题

Q1:已经存在的单表怎么转分区表?

三种方式:

-- 方式一:CREATE TABLE ... AS SELECT(适合维护窗口)
CREATE TABLE orders_new (LIKE orders INCLUDING ALL) 
PARTITION BY RANGE (created_at) ...;
INSERT INTO orders_new SELECT * FROM orders;
DROP TABLE orders;
ALTER TABLE orders_new RENAME TO orders;

-- 方式二:使用 pg_repack 在线重建(零停机)
pg_repack -t orders --create-partition ...

-- 方式三:ETL 方式分批迁移(最安全)
CREATE TABLE orders_partitioned (... PARTITION BY RANGE ...);
-- 按天插入历史数据,每天一批
INSERT INTO orders_partitioned SELECT * FROM orders 
WHERE created_at >= '2025-01-17' AND created_at < '2025-01-18';

推荐方式三,风险最低。

Q2:子分区数量多少合适?

子分区数建议 = CPU 核数或 2 的幂(方便哈希分布)。我们 32 核鲲鹏选了 16 个子分区,每个分区数据均匀。不是越多越好——太多子分区会导致查询时遍历元数据开销增大。

Q3:分区表的全局索引有坑吗?

openGauss 5.0 支持分区表上的全局索引,但全局索引在分区维护(DROP/TRUNCATE)时会变为不可用:

-- TRUNCATE 分区后需要重建全局索引
ALTER TABLE orders_partitioned TRUNCATE PARTITION p20241017;
REINDEX INDEX idx_orders_user_id_global;

建议:尽量使用本地索引(分区内索引),避免维护麻烦。

Q4:分区键可以修改吗?

不可以。分区键在建表时固定。如果需要修改分区策略,只能重建表。

✅ 分区表设计检查清单

【设计阶段】
□ 分区键是否覆盖了核心查询条件(WHERE 条件前缀)?
□ 主键/唯一索引是否包含了所有分区键?
□ 子分区数是否合理(2^n,不大于 CPU 核数 × 2)?
□ 是否有 MAXVALUE 兜底分区?

【实施阶段】
□ 验证分区裁剪是否生效(EXPLAIN 看 Subplans Removed)
□ 查询条件写法是否导致裁剪失效(DATE() 包裹、隐式类型转换)
□ 冷热数据分离策略是否制定
□ 分区自动创建脚本是否就绪

【运维阶段】
□ 分区自动创建是否通过 crontab 定时执行
□ 冷数据迁移是否定期执行
□ 全局索引是否需要在分区维护后重建
□ 监控是否有数据写入 MAXVALUE 分区(意味着新分区创建失败)

📝 总结

分区表是解决亿级大表性能问题最有效的手段。我的策略很简单:

  1. 热点查询必须能够被裁剪到 10 个以内分区,否则分区意义不大
  2. 创建分区 + 索引,不要只创建分区,分区只是缩小了扫描范围,真正的点查还是要靠索引
  3. 冷热分离 + 分区 是一对组合拳,一个管查询性能,一个管存储成本

数据量超过 5 千万行的表,如果没有分区,就该拉警报了。

下一篇文章讲 openGauss 主备高可用方案 —— Keepalived + 流式复制实现 RTO < 10 秒。

💬 互动:你们生产库上最大的表有多少行?用的是什么分区策略?欢迎评论区聊聊。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。