分区表一直以来都是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 )在较小的分区块上运行速度更快。
让我们举个例子来看看它是如何工作的:
-- 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数据进行分区,以进一步优化查询性能:
-- 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 必须遍历多个级别才能找到目标分区。
当您执行如下查询时:
SELECT * FROM events
WHERE user_id = 101 AND event_type = 101;
PostgreSQL 的query plan必须执行几个昂贵的操作:
规划器分析您的WHERE子句以确定每个分区键的值(user_id和event_type)。
PostgreSQL 使用其内部哈希函数。例如:
hashint8extended(101, seed) 用于 bigint 类型的 user_idhashint4extended(101, seed) 用于 integer 类型的 event_type使用计算出的哈希值,PostgreSQL 必须:
(hash_user_id + magic_constant) % 16 得到 biginthash_event_type % 8 得到 integer(无需魔法常数)叶子分区表表名PostgreSQL 执行额外的元数据查找以:
在高查询负载(常见于OLTP)下,这些遍历 catalog 的 CPU 开销会变得非常大。即使应用程序已经确切知道哪个分区包含数据,每个查询仍需要等待规划器完成这个多步骤的过程。分区层次越深,这些查找的开销就越大。
事情变得有趣起来。在许多应用程序中,您已经掌握了所查询数据的上下文。您知道具体的user_id和event_type值。您甚至可能从应用程序逻辑中知道您正在寻找用户 101 的 101 事件类型。
如果您的应用程序不必每次都费力地查找 catalog,而是可以直接计算目标分区,会怎么样?而不是 :
SELECT * FROM events
WHERE user_id = 101 AND event_type = 101;
您可以查询确切的分区:
SELECT * FROM events_0_5
WHERE user_id = 101 AND event_type = 101;
这完全绕过了 PostgreSQL 的 catalog 遍历。无需哈希函数调用,无需 pg_class 查找,无需层级结构导航 —— 只需直接查询包含数据的分区即可。同时,分区数据仍然能够带来性能提升、索引更小、自动清理压力降低以及维护操作更便捷的优势。
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 将 int2 和 int4 视为相同)
该 gem 专注于整数类型, 尚不支持text/string、UUID 或其他数据类型的哈希分区键。
该 gem 处理 PostgreSQL 内部使用的所有复杂位操作、有符号/无符号算术转换以及魔法常量。使用方法如下:
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 中的实际行为进行了测试。总的来说,这本身就是一次旅程,但那是另一篇博客文章了。
如果您对基于 SQL 的解决方案感到满意,或者不使用 ruby 但想避免完整的 catalog 遍历,则可以直接调用 PostgreSQL 的哈希函数:
-- 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查询时网络往返的开销。
基准测试( https://github.com/shayonj/pg_hash_func/blob/main/benchmarks/file.rb#L104 )显示了性能差异:
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 变得异常灵活。