首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >再谈PostgreSQL分区表! 这样可以提速20倍

再谈PostgreSQL分区表! 这样可以提速20倍

作者头像
用户4035096
发布2026-07-09 18:53:39
发布2026-07-09 18:53:39
140
举报

再谈PostgreSQL分区表! 通过直接哈希分区计算绕过 PostgreSQL catalog 开销, 提速20倍

分区表一直以来都是PostgreSQL的痛, 当分区过多时, 元数据锁放大问题、分区查找CPU消耗问题等有可能成为瓶颈.

虽然PostgreSQL 从10开始就在不断优化分区表的性能, 但是始终还没有达到完美的境界.

所以, 如果你的应用程序能自主识别分区表的确定分区, 在应用端直接访问分区是最高效的.

一位国外友人使用Claude 4编写了一个ruby库, 嫁接了PostgreSQL内置哈希分区的分区计算代码(src/common/hashfn.c 以及 src/backend/access/hash/hashfunc.c), 使得分区查找性能提升了20倍, 让我们来看看他怎么做的.

以下内容翻译自:

https://www.shayon.dev/post/2025/221/bypass-postgresql-catalog-overhead-with-direct-partition-hash-calculations/

PostgreSQL 的哈希分区使用确定性哈希函数将tuple分布到各个分区。当您查询父表时,PostgreSQL 必须执行 catalog 查找,以将每个 query 路由到正确的分区。这会给高吞吐量应用程序(OLTP)带来明显的开销,尤其是使用多级分区时,PostgreSQL 必须遍历更深的 catalog 结构才能识别目标分区。

本文将分享在已知分区键的值的情况下如何提高速度。

什么是基于哈希的分区?

hash分区使用应用于分区键的hash func将tuple分布到不同分区中。与range分区或list分区不同,hash分区无需了解数据模式即可确保相对均匀的分布。

您可以使用哈希分区来分解大型表并分配负载, 将大表拆成多个小表(分区)。由于每个分区维护的索引较小,因此索引查找速度更快。此外,它还允许 PostgreSQL autovacuum 独立清理较小的分区,而不是处理一个大型表,从而减轻了vacuum压力。此外,当 PostgreSQL 可以在QUERY规划(分区裁剪)期间消除不需要的分区时,查询性能也会得到提升,并且维护操作(例如 REINDEX / VACUUM )在较小的分区块上运行速度更快。

让我们举个例子来看看它是如何工作的:

代码语言:javascript
复制
-- Creating a hash-partitioned table  
CREATE TABLE events (  
    id bigint,  
    user_id bigint,  
    event_type integer,  
    payload text,  
    created_at timestamp  
) PARTITION BY HASH (user_id);  
  
-- Creating the actual partitions  
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都会被确定地映射到某个分区中。用户 101 始终位于同一个分区,但不同的用户 ID 可能最终位于不同的分区。

哈希分区非常适合用于访问模式不可预测的大容量表、需要跨分区并行处理的场景,以及需要负载均衡且避免热点的情况。

对于复杂的系统,您可以实施两级分区:首先按用户user_id进行数据分区,然后按事件类型event_type进一步进行数据分区。

两级分区

现在假设您需要更细粒度的数据分布来优化查询,您可以创建两级哈希分区。假设有一个高容量事件系统,您需要首先按user_id数据进行分区,然后按每个用户内部的event_type数据进行分区,以进一步优化查询性能:

代码语言:javascript
复制
-- Parent table  
CREATE TABLE events (  
    id bigint,  
    user_id bigint,  
    event_type integer,  
    payload text,  
    created_at timestamp  
) PARTITION BY HASH (user_id);  

-- First level: 16 partitions by user_id  
CREATE TABLE events_0 PARTITION OF events  
  FOR VALUES WITH (modulus 16, remainder 0)  
  PARTITION BY HASH (event_type);  

CREATE TABLE events_1 PARTITION OF events  
  FOR VALUES WITH (modulus 16, remainder 1)  
  PARTITION BY HASH (event_type);  
-- ... continuefor all 16 first-level partitions  

-- Second level: 8 partitions per user by event_type  
CREATE TABLE events_0_0 PARTITION OF events_0  
  FOR VALUES WITH (modulus 8, remainder 0);  
CREATE TABLE events_0_1 PARTITION OF events_0  
  FOR VALUES WITH (modulus 8, remainder 1);  
-- ... continuefor all combinations, resulting in 128 total leaf partitions (16 × 8)  

这会创建一个分区层次结构,PostgreSQL 必须遍历多个级别才能找到目标分区。

catalog 查找开销

当您执行如下查询时:

代码语言:javascript
复制
SELECT * FROM events  
WHERE user_id = 101 AND event_type = 101;  

PostgreSQL 的query plan必须执行几个昂贵的操作:

步骤 1:解析分区键

规划器分析您的WHERE子句以确定每个分区键的值(user_idevent_type)。

步骤 2:计算哈希值

PostgreSQL 使用其内部哈希函数。例如:

  • hashint8extended(101, seed) 用于 bigint 类型的 user_id
  • hashint4extended(101, seed) 用于 integer 类型的 event_type

步骤 3:导航分区层次结构

使用计算出的哈希值,PostgreSQL 必须:

  • 查询 pg_class 找到父表的分区 OID
  • 遍历 pg_inherits 找到父子关系映射
  • 定位一级分区:(hash_user_id + magic_constant) % 16 得到 bigint
  • 查找该一级分区的子分区
  • 定位二级分区:hash_event_type % 8 得到 integer(无需魔法常数)
  • 解析得到最终的叶子分区表表名

步骤 4:制定执行计划

PostgreSQL 执行额外的元数据查找以:

  • 验证分区约束是否与计算出的哈希值匹配
  • 应用分区裁剪优化
  • 生成针对特定分区的最终查询计划

在高查询负载(常见于OLTP)下,这些遍历 catalog 的 CPU 开销会变得非常大。即使应用程序已经确切知道哪个分区包含数据,每个查询仍需要等待规划器完成这个多步骤的过程。分区层次越深,这些查找的开销就越大。

如果您已经知道分区详细信息呢?

事情变得有趣起来。在许多应用程序中,您已经掌握了所查询数据的上下文。您知道具体的user_idevent_type值。您甚至可能从应用程序逻辑中知道您正在寻找用户 101101 事件类型。

如果您的应用程序不必每次都费力地查找 catalog,而是可以直接计算目标分区,会怎么样?而不是 :

代码语言:javascript
复制
SELECT * FROM events  
WHERE user_id = 101 AND event_type = 101;  

您可以查询确切的分区:

代码语言:javascript
复制
SELECT * FROM events_0_5  
WHERE user_id = 101 AND event_type = 101;  

这完全绕过了 PostgreSQL 的 catalog 遍历。无需哈希函数调用,无需 pg_class 查找,无需层级结构导航 —— 只需直接查询包含数据的分区即可。同时,分区数据仍然能够带来性能提升、索引更小、自动清理压力降低以及维护操作更便捷的优势。

这就是 pg_hash_func 的用武之地。

pg_hash_func ( https://github.com/shayonj/pg_hash_func ) 是一个 Ruby 库,用于对 PostgreSQL 的内部哈希分区逻辑进行逆向工程。它复制了 PostgreSQL 的 lookup3 哈希函数( 来自 src/common/hashfn.c ),以及 src/backend/access/hash/hashfunc.c 中的分区特定逻辑。

该 gem 目前支持 PostgreSQL 的基于整数的哈希分区:

bigint ( int8 ) - calculate_partition_index_bigint 与魔法常数一起使用 integer ( int4 ) - calculate_partition_index_int4 不使用魔法常数 smallint( int2 ) - calculate_partition_index_int4(PostgreSQL 将 int2int4 视为相同)

该 gem 专注于整数类型, 尚不支持text/string、UUID 或其他数据类型的哈希分区键。

该 gem 处理 PostgreSQL 内部使用的所有复杂位操作、有符号/无符号算术转换以及魔法常量。使用方法如下:

代码语言:javascript
复制
require 'pg_hash_func'  

# Calculate first-level partition for user_id (bigint)  
user_partition = PgHashFunc.calculate_partition_index_bigint(  
  value: 101,  
  num_partitions: 16  
)  
# => 0  

# Calculate second-level partition for event_type (integer)  
event_partition = PgHashFunc.calculate_partition_index_int4(  
  value: 101,  
  num_partitions: 8  
)  
# => 5  

# Construct the target partition name  
target_table = "events_#{user_partition}_#{event_partition}"
# => "events_0_5"  

# Query directly - no catalog lookup overhead!  
result = db.exec("SELECT * FROM #{target_table} WHERE user_id = 101 AND event_type = 101")  

构建这个 gem 让我学到了很多关于位操作、哈希算法以及 PostgreSQL 的 uint64 运算怪癖的知识。感谢 Claude 4 帮助我解锁了一些 Ruby 中的位操作技巧。这个 gem 全面覆盖了规范,并针对 PostgreSQL 在 CI 中的实际行为进行了测试。总的来说,这本身就是一次旅程,但那是另一篇博客文章了。

直接调用 PostgreSQL 函数

如果您对基于 SQL 的解决方案感到满意,或者不使用 ruby 但想避免完整的 catalog 遍历,则可以直接调用 PostgreSQL 的哈希函数:

代码语言:javascript
复制
-- Calculate partitions using PostgreSQL functions directly  
-- For bigint: ((hash + magic_constant) % 2^64) % num_partitions, then ensure non-negative  
-- For int4: (hash % 2^64) % num_partitions, then ensure non-negative  
SELECT  
  -- First level: user_id partition (bigint with magic constant)  
  (((((hashint8extended(101::bigint, 8889443574745275645::bigint)::numeric + 5270998738748236643::numeric) % 18446744073709551616::numeric) % 16::numeric) + 16::numeric) % 16::numeric) as user_partition,  
  -- Second level: event_type partition (int4, no magic constant)  
  ((((hashint4extended(101::integer, 8889443574745275645::bigint)::numeric % 18446744073709551616::numeric) % 8::numeric) + 8::numeric) % 8::numeric) as event_partition;  
-- Results: user_partition = 0, event_partition = 5  

这会为您提供无需 catalog 查找的分区索引,但您仍然需要忍耐 SQL查询时网络往返的开销。

速度优势(最高可提高 20 倍)

基准测试( https://github.com/shayonj/pg_hash_func/blob/main/benchmarks/file.rb#L104 )显示了性能差异:

代码语言:javascript
复制
Ruby Calculation (int4):   121,964.6 i/s  
SQL Query (int4):           5,948.9 i/s - 20.50x slower  

Ruby 计算速度比 SQL 快 20-40 倍。这样做的好处当然在于,它完全消除了网络往返,同时获得了与 PostgreSQL 完全相同的正确分区名。此外,数据库无需消耗额外的 CPU 和负载来查询catalog表。

结论

PostgreSQL 的优点在于它提供了多种选择。根据你的承受能力和性能需求,你可以:

  • 为了简单起见,使用父表并让 PostgreSQL 自动处理所有事情
  • 直接调用 PostgreSQL 的哈希函数,在 SQL 中跳过catalog遍历
  • 在应用程序代码中计算分区索引以消除所有数据库开销

在我看来,每种方法都有其适用之处。对于大多数应用程序来说,标准的父表方法运行良好。但是,当处理高吞吐量、延迟敏感的工作负载时,能够在应用层进行优化,同时保留分区的所有优势,这使得 PostgreSQL 变得异常灵活。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-08-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 digoal德哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 再谈PostgreSQL分区表! 通过直接哈希分区计算绕过 PostgreSQL catalog 开销, 提速20倍
  • 什么是基于哈希的分区?
  • 两级分区
  • catalog 查找开销
    • 步骤 1:解析分区键
    • 步骤 2:计算哈希值
    • 步骤 3:导航分区层次结构
    • 步骤 4:制定执行计划
  • 如果您已经知道分区详细信息呢?
  • 这就是 pg_hash_func 的用武之地。
  • 直接调用 PostgreSQL 函数
  • 速度优势(最高可提高 20 倍)
  • 结论
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档