PostgreSQL 中表列的排序
对于 GitLab,我们要求新表的列按照使用最少空间的方式进行排序。一个简单的方法是根据类型大小降序排列,将可变长度的类型(如 text、varchar、数组、json、jsonb 等)放在最后。
类似于 C 结构,表的内存占用受列顺序的影响。这是因为列的大小会根据后续列的类型进行对齐。让我们看一个例子:
id(integer, 4 字节)name(text, 可变长度)user_id(integer, 4 字节)
第一列是一个 4 字节的整数。下一列是可变长度的 text。text 数据类型需要 1 字词对齐,在 64 位平台上,1 个词是 8 字节。为了满足对齐要求,需要在第一列后面添加 4 个零,因此 id 占用 4 字节,然后是 4 字节的对齐填充,接下来才存储 name。因此,在这种情况下,存储一个 4 字节的整数需要花费 8 字节。
行之间的空间也会受到对齐填充的影响。user_id 列只占用 4 字节,在 64 位平台上,会添加 4 个零作为对齐填充,以便允许下一行以"干净"的词开始。
结果,每列的实际大小将是(忽略可变长度数据和 24 字节的元组头):8 字节、可变、8 字节。这意味着每行至少需要 16 字节来存储两个 4 字节的整数。如果一个表只有几行,这不是问题。但是,一旦你开始存储数百万行,通过使用不同的顺序就可以节省空间。对于上面的例子,理想的列顺序如下:
id(integer, 4 字节)user_id(integer, 4 字节)name(text, 可变)
或者
name(text, 可变)id(integer, 4 字节)user_id(integer, 4 字节)
在这些例子中,id 和 user_id 列被打包在一起,这意味着我们只需要 8 字节来存储它们。这反过来意味着每行需要少占用 8 字节的空间。
从 Ruby on Rails 5.1 开始,ID 的默认数据类型是 bigint,它使用 8 字节。我们在示例中使用 integer 是为了展示一个更真实的重新排序场景。
类型大小
虽然 PostgreSQL 文档 包含了大量信息,但我们在这里列出常见类型的大小,以便更容易查找。这里的"词"指的是字长,在 32 位平台上是 4 字节,在 64 位平台上是 8 字节。
| 类型 | 大小 | 所需对齐 |
|---|---|---|
smallint |
2 字节 | 1 词 |
integer |
4 字节 | 1 词 |
bigint |
8 字节 | 8 字节 |
real |
4 字节 | 1 词 |
double precision |
8 字节 | 8 字节 |
boolean |
1 字节 | 不需要 |
text / string |
可变,1 字节加数据 | 1 词 |
bytea |
可变,1 或 4 字节加数据 | 1 词 |
timestamp |
8 字节 | 8 字节 |
timestamptz |
8 字节 | 8 字节 |
date |
4 字节 | 1 词 |
“可变"大小意味着实际大小取决于存储的值。如果 PostgreSQL 确定可以直接嵌入到行中,它可能会这样做,但对于非常大的值,它会将数据存储在外部,并在列中存储一个指针(大小为 1 词)。因此,可变大小的列应该始终放在表的末尾。
实际例子
让我们以 events 表为例,它目前有以下布局:
| 列 | 类型 | 大小 |
|---|---|---|
id |
integer | 4 字节 |
target_type |
character varying | 可变 |
target_id |
integer | 4 字节 |
title |
character varying | 可变 |
data |
text | 可变 |
project_id |
integer | 4 字节 |
created_at |
timestamp without time zone | 8 字节 |
updated_at |
timestamp without time zone | 8 字节 |
action |
integer | 4 字节 |
author_id |
integer | 4 字节 |
在对齐列添加填充后,这会将列分成固定大小的块,如下所示:
| 块大小 | 列 |
|---|---|
| 8 字节 | id |
| 可变 | target_type |
| 8 字节 | target_id |
| 可变 | title |
| 可变 | data |
| 8 字节 | project_id |
| 8 字节 | created_at |
| 8 字节 | updated_at |
| 8 字节 | action, author_id |
这意味着忽略可变大小数据和元组头,我们每行至少需要 8 * 6 = 48 字节。
我们可以通过使用以下列顺序来优化:
| 列 | 类型 | 大小 |
|---|---|---|
created_at |
timestamp without time zone | 8 字节 |
updated_at |
timestamp without time zone | 8 字节 |
id |
integer | 4 字节 |
target_id |
integer | 4 字节 |
project_id |
integer | 4 字节 |
action |
integer | 4 字节 |
author_id |
integer | 4 字节 |
target_type |
character varying | 可变 |
title |
character varying | 可变 |
data |
text | 可变 |
这将产生以下块:
| 块大小 | 列 |
|---|---|
| 8 字节 | created_at |
| 8 字节 | updated_at |
| 8 字节 | id, target_id |
| 8 字节 | project_id, action |
| 8 字节 | author_id |
| 可变 | target_type |
| 可变 | title |
| 可变 | data |
在这里,忽略可变大小数据和 24 字节元组头,我们每行只需要 40 字节。节省 8 字节听起来可能不多,但对于像 events 表这样的大表来说,这确实开始变得重要。例如,当存储 80,000,000 行时,这至少可以节省 610 MB 的空间,而这仅仅是通过改变几列的顺序实现的。