在 Postgres 12 或 13 中根据 UUID 进行分区

Mor*_*ryx 3 sql postgresql uuid database-design partitioning

问题

我被要求将大量数据复制到 Postgres 的新表中。数据包含装配组件列表,在下面的表定义中进行了简化:

CREATE TABLE IF NOT EXISTS assembly_item (
    id               uuid       NOT NULL DEFAULT  NULL,
    assembly_id.     uuid.      NOT NULL DEFAULT  NULL,
    done_dts         timestamp  NOT NULL DEFAULT 'epoch', 

CONSTRAINT assembly_item_pk
    PRIMARY KEY (id) 
);
Run Code Online (Sandbox Code Playgroud)

原来有几十个属性,现在有几亿行。这些记录分布在多个安装中,并且不存储在本地 Postgres 中。据猜测,该表上的插入量会快速增加,一年内将增长到 1B 行。日期很少更新,也从不删除。(它可能会及时发生,但不会经常发生。)相同的情况永远不会id用不同的值重复。因此,在分区级别上唯一是安全的。这里的目标是将这些数据卸载到 Postgres 上,并仅将最近的数据保留在本地服务器的缓存中。assembly_idid

这看起来像是分区的自然候选者,我正在寻找一些有关合理策略的指导。您可以从简化的结构中看到,我们有一个唯一的 row id、一个parentassembly_id和一个时间戳。我查看了原始数据库中的现有查询,主要搜索字段是assembly_id父记录标识符。assembly和之间的基数assembly_item约为 1:200。

为了使分区最有用,似乎需要根据一个值来分割数据,该值使查询规划器能够智能地修剪分区。我已经想到了一些想法,但还没有 200M 行来再次测试。与此同时,我正在考虑的是:

  • RANGE使用或LISTYYYY-MM的进行按月分区done_dts。按日期范围重写所有查询。

  • HASH根据 的前两个字符进行分区assembly_id::text,得到 256 个大小相当相等的分区。我认为这可以让我们搜索assembly_id并删除许多不匹配的分区,但当我设置它时,它看起来很奇怪。

我很感激我问了一个有点推测性的问题,我所希望的只是一些可能使我的第一次尝试更加成功的指示。一旦我获得了一些数据集,我就可以更直接地进行实验。

我已经包含了实验设置代码,为了简洁起见,仅列出了部分分区的示例。

使用LIST分区的示例设置

------------------------------------
-- Define table partitioned by list
------------------------------------
-- Could alternatively use RANGE here to partition by month.

BEGIN;

-- Drop parent table, if they exists.
-- This destroys ALL partitions automatically, even without a CASCADE clause.
DROP TABLE IF EXISTS assembly_item_list CASCADE;

CREATE TABLE IF NOT EXISTS assembly_item_list (
    id                              uuid          NOT NULL DEFAULT NULL,
    assembly_id                     uuid          NOT NULL DEFAULT NULL,
    assembly_done_dts               timestamp     NOT NULL DEFAULT 'epoch', -- Copied in from assembly.done_dts when rows are pushed to Postgres.
    year_and_month                  citext        NOT NULL DEFAULT NULL,    -- YYYY-MM from assembly_done_dts, calculated in insert function. Can't use a generated column as a partition key.

-- Reminder: id values come from the various source tables in IB. The upsert writes over matches ON CONFLICT with this ID.
-- Note: You *must* include the partition key in the primary key. It's a rule.
CONSTRAINT assembly_item_list_pk
    PRIMARY KEY (year_and_month, id) 
) PARTITION BY LIST (year_and_month);

-- Previous year partitions built here...

-- Build out 2021 completely.
CREATE TABLE assembly_item_list_2021_01 partition of assembly_item_list HASH (assembly_id) ('2021-01');
CREATE TABLE assembly_item_list_2021_02 partition of assembly_item_list HASH (assembly_id) ('2021-02');
-- etc.

-- In case I screw up at the end of the year....
CREATE TABLE assembly_item_list_default partition of assembly_item_list default; 

COMMIT; 
Run Code Online (Sandbox Code Playgroud)

使用分区的示例设置HASH

------------------------------------
-- Define table partitioned by hash
------------------------------------

BEGIN;

-- Drop parent table, if they exists.
-- This destroys ALL partitions automatically, even without a CASCADE clause.
DROP TABLE IF EXISTS assembly_item_hash CASCADE;

CREATE TABLE IF NOT EXISTS assembly_item_hash (
    id                              uuid          NOT NULL DEFAULT NULL,
    assembly_id                     uuid          NOT NULL DEFAULT NULL,
    assembly_done_dts               timestamp     NOT NULL DEFAULT 'epoch', -- Copied in from assembly.done_dts when rows are pushed to Postgres.
    partition_key                   text          NOT NULL DEFAULT NULL,    -- '00', '0A', etc. Populated in a BEFORE INSERT trigger on the partition. Can't use a generated column as a partition key, can't use a column reference in DEFAULT. 

-- Reminder: id values come from the various source tables in IB. The upsert writes over matches ON CONFLICT with this ID.
-- Note: You *must* include the partition key in the primary key. It's a rule.
CONSTRAINT assembly_item_hash_pk
    PRIMARY KEY (partition_key, id) 
) PARTITION BY HASH (partition_key);

-----------------------------------------------------
-- Create trigger function to populate partition_key
-----------------------------------------------------
-- The partition key is a two-character hex string, like '00', '3E', and so on.
CREATE OR REPLACE FUNCTION set_partition_key()
    RETURNS TRIGGER AS $$
    BEGIN
        NEW.partition_key = UPPER(LEFT(NEW.assembly_id, 2));
        RETURN NEW;
END;
$$ language plpgsql IMMUTABLE; -- I don't think that I need to worry about IMMUTABLE here. 01234567890ABCDEF shouldn't break. 

-----------------------------------------------------
-- Build partitions
-----------------------------------------------------
-- Note: Have to assign triggers to partitions individually.
-- Seems that it would be easier to add the logic to my central insert function.

CREATE TABLE assembly_item_hash_00 partition of assembly_item_hash FOR VALUES WITH (modulus 256, remainder 0);
CREATE TRIGGER set_partition_key_trigger_00
    BEFORE INSERT OR UPDATE ON assembly_item_hash_00
    FOR EACH ROW
    EXECUTE PROCEDURE set_partition_key();

CREATE TABLE assembly_item_hash_01 partition of assembly_item_hash FOR VALUES WITH (modulus 256, remainder 1);
CREATE TRIGGER set_partition_key_trigger_01
    BEFORE INSERT OR UPDATE ON assembly_item_hash_01
    FOR EACH ROW
    EXECUTE PROCEDURE set_partition_key();
    
-- And so on for all 256 partitions.

COMMIT; 
Run Code Online (Sandbox Code Playgroud)

有什么建议吗?真的,有什么想到的吗?

Erw*_*ter 8

我不能说日期或 UUID 哈希是否是更好的分区键。但我可以这样说:你们的解决方案都可以更有效。

哈希分区基于uuid

您添加分区键列并使用触发器函数填充它的计划效率非常低。而且没有必要。(除了触发功能本身的问题之外。)

似乎有什么误会。您有评论:

-- 注意:主键中必须包含分区键。这是一条规则。

不完全是。手册:

分区表上的唯一约束(以及主键)必须包括所有分区键列。存在这种限制是因为构成约束的各个索引只能直接在自己的分区内强制执行唯一性;因此,分区结构本身必须保证不同分区之间不存在重复。

分区键。不是分区键。
具有哈希分区的设置可(assembly_id)与同一列上的 PK 配合使用。像这样:

CREATE TABLE IF NOT EXISTS assembly_item_hash (
  assembly_id       uuid      NOT NULL
, id                uuid      NOT NULL
, assembly_done_dts timestamp NOT NULL DEFAULT 'epoch'
, PRIMARY KEY (assembly_id, id)
) PARTITION BY HASH (assembly_id);

CREATE TABLE assembly_item_hash_000 PARTITION OF assembly_item_hash FOR VALUES WITH (MODULUS 256, REMAINDER 0);
CREATE TABLE assembly_item_hash_001 PARTITION OF assembly_item_hash FOR VALUES WITH (MODULUS 256, REMAINDER 1);
-- etc.
Run Code Online (Sandbox Code Playgroud)

简单多了

唯一的缺点:PK索引较大,uuid占用16字节。

如果这是一个问题,您可能会回到partition_key您想要的生成方式。每个分区都有一个触发器。(呃,开销!)但是用列integer代替text,并使用高效的内置哈希函数uuid_hash()。这是内部用于哈希分区的函数。但现在我们显式地使用它并进行LIST分区:

CREATE TABLE IF NOT EXISTS assembly_item_hash (
  id                uuid      NOT NULL
, assembly_id       uuid      NOT NULL
, partition_key     int4      NOT NULL
, assembly_done_dts timestamp NOT NULL DEFAULT 'epoch'
, PRIMARY KEY (partition_key, id)
) PARTITION BY LIST (partition_key);
Run Code Online (Sandbox Code Playgroud)

理论上,为每个表行添加 4 个字节,为每个索引项节省 12 个字节。由于对齐填充,您在表和索引中又丢失了 4 个字节,最终磁盘上的总空间与以前相同(大致上 - 表和索引膨胀可能不同)。 除非 “列俄罗斯方块”允许您更有效地适应该列,否则每行总共赢得最多 8 个字节......请参阅:

列表分区基于timestamp

不要使用citext. 不必要的复杂化。

请改用整数表示 YYYY-MM。更小,更快。我建议这个基本功能:

CREATE FUNCTION f_yyyymm(timestamp)
   RETURNS int
   LANGUAGE sql PARALLEL SAFE IMMUTABLE AS
'SELECT (EXTRACT(year FROM $1) * 100 + EXTRACT(month FROM $1))::int';
Run Code Online (Sandbox Code Playgroud)

看: