为什么 Postgres 中的更新操作有时感觉比插入操作慢?答案在于 Postgres 如何在后台管理数据版本。
Postgres 高效处理并发事务能力的核心是多版本并发控制(MVCC)。
在本文中,我将探讨 MVCC 在 Postgres 中的工作原理以及它如何影响写入和读取,以及插入和更新之间的性能差异。
-- pageinspect 扩展用于查看底层数据存储,我们很快就会用到它!
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 创建表存储
CREATE TABLE store (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL ,
value INT NOT NULL );
-- 禁用表的自动清理
ALTER TABLE store SET (autovacuum_enabled = false );
设置过程包括创建一个名为“store”的示例表并禁用自动清理。这将使我们能够观察 Postgres 如何在没有自动清理的情况下处理版本控制(MVCC 的实际操作),从而帮助我们更好地理解 MVCC 的工作原理。
MVCC概述
为了理解为什么更新行为与插入不同,我们首先看看 MVCC 在 Postgres 中的工作方式。
多版本并发控制 (MVCC) 是 Postgres 用于处理同一行的并发事务同时保持数据一致性和隔离性的机制。
Postgres 不会在读取和写入期间锁定行,而是创建一行的多个版本,以允许事务在不相互阻塞的情况下运行。
一行的多个版本被称为元组 (Tuples)。如果插入一行,则有 1 个元组与之关联。如果更新同一行,则存在 2 个元组,其中一个是活的,另一个是死的。
工作原理如下:
写入操作
每个插入或更新查询都在一个事务中执行,每个事务都会被分配一个唯一的事务 ID。虽然该过程还涉及写入预写日志 (WAL) 和设置检查点,但本文不会介绍这些细节。
您可以通过以下方式检查postgres中的当前事务ID:
postgres = # SELECT txid_current();
txid_current
--------------
753
( 1 行)
postgres = # SELECT txid_current();
txid_current
--------------
754 ( 1 行)
Postgres 中的每一行都包含与事务 ID 绑定的版本信息,用于跟踪该行随时间的变化状态。此版本控制通过 xmin 和 xmax 值进行管理,这些值可以直接查询。
postgres = #插入 store (name, value )值( 'score' , 10 );插入 0 1
postgres = #从store中选择xmin, xmax, * 其中id = 1 ; xmin | xmax | id | name | value ------+------+----+-------+------- 755 | 0 | 1 | score | 10 ( 1行)
- min表示插入该行(即创建初始元组)的事务的 ID。
- xmax表示删除或使该行失效的事务的 ID。由于这里的 xmax 为 0,因此表示该行处于活动状态,未被删除或失效。
Read Operation这些值也会影响行可见性,我们将在下面的文章中更详细地探讨。
让我们看一下数据库中存储的实际元组,看看 MVCC 内部是如何管理行版本的。我们可以使用 pageinspect 扩展直接检查元组,这使我们能够查看表底层页面的原始内容。
Postgres 以页的形式将数据存储在磁盘上,每个页包含多个元组。理想情况下,我们感兴趣的元组应该位于第一页(即第 0 页)。让我们使用 pageinspect 来查询它,看看存储了什么:
postgres = #从heap_page_items(get_raw_page( 'store' , 0 ))中选择lp、t_ctid、t_xmin、t_xmax ; lp | t_ctid | t_xmin | t_xmax ----+--------+--------+-------- 1 | ( 0 , 1 ) | 755 | 0 ( 1行)
我们可以看到 xmin 和 xmax 值与表上的常规 SELECT 查询的值匹配。
- lp– 这是行指针,充当页面内的索引,指向元组的实际位置。由于lp = 1,这意味着这是页面中第一个且唯一的元组。
- t_ctid– 这是元组的 ID,以 (page_number, tuple_number)格式指向元组的物理位置。如果该行被更新,则会创建一个新版本,并且 ctid 将指向新的位置——稍后会详细介绍。
- xmin–表示插入该行的事务 ID(在本例中为事务 755)。
- xmax– 表示删除或使该行失效的事务 ID。由于 xmax = 0,因此该行仍然有效。 Postgres 中的元组由系统列(例如 xmin、xmax、ctid 等)和实际行数据组成。系统列帮助 Postgres 管理行版本和可见性,稍后我们将更详细地探讨这些内容。
当发生写入操作(例如更新)时,现有行不会被直接修改。相反,Postgres 会创建一个包含更新值的新行版本,并保持旧行不变。然后,旧元组会被标记为“已死”,但仍可用于 MVCC 用途(例如支持并发读取)。
让我们更新行并检查更改:
postgres = # UPDATE store set value = 20 where id = 1 ;
UPDATE 1
postgres = # SELECT xmin, xmax, * FROM store WHERE id = 1 ;
xmin | xmax | id | name | value
------+------+----+-------+-------
756 | 0 | 1 | score | 20
( 1 行)
该行现在有了一个新的 xmin 值 (756),它代表创建此新元组的事务 ID。由于 MVCC 机制,我们预期原始行(现在是一个死元组)仍然与新行并存。
我们可以通过使用视图检查活元组和死元组的数量来确认这一点pg_stat_all_tables:
postgres = #从pg_stat_all_tables中选择n_live_tup、n_dead_tup、relname ,其中relname = 'store';n_live_tup | n_dead_tup | relname ------------+------------+--------- 1 | 1 | store (1行)
正如预期的那样,有 1 个活动元组(更新后的行)和 1 个死亡元组(原始行)。现在让我们检查底层页面以查看这两个元组:
让我们查询页面来再次查看元组。
postgres = #从heap_page_items(get_raw_page( 'store' , 0 ))中选择lp、t_ctid、t_xmin、t_xmax ; lp | t_ctid | t_xmin | t_xmax ----+--------+--------+-------- 1 | ( 0 , 2 ) | 755 | 756 2 | ( 0 , 2 ) | 756 | 0 ( 2行)
我们现在看到了预期的两个元组:
- 第一个元组有 xmin = 755(来自原始插入)和 xmax = 756,表明它被事务 756(更新)无效。
- 第二个元组是更新创建的新版本,其中 xmin = 756 和 xmax = 0(表示它仍然有效)。
- 请注意,t_ctid原始元组的 已从更改为(0,1)。(0,2)这是因为t_ctid指向该行的最新版本。该格式(page_number,line_pointer)表示该行的最新版本位于第 0 页,行指针为 2。
Heap Only Tuples (HOT)
此行为是 Postgres 一项名为“仅堆元组 (HOT)”的优化的一部分。在执行 UPDATE 操作时,Postgres 不会立即更新索引以指向新元组(这样做成本较高),而是会更新旧元组的 t_ctid 以指向新版本。这会创建一个元组链——索引指向旧元组,旧元组指向新元组,依此类推。
Postgres 依靠 Vacuum 进程来清理死元组,并在稍后更新索引。
您可以在 Postgres 官方文档中了解更多关于 HOT 的信息:
👉 https://www.postgresql.org/docs/current/storage-hot.html
Vacuum Process
你可能已经注意到,在之前的例子中,vacuum 操作被禁用了。这是故意为之,因为它允许我们观察死元组和 HOT 链,否则它们会被vacuum 进程清理掉。
真空在 Postgres 中扮演着至关重要的角色,它可以永久删除死元组、回收存储空间,并更新索引以消除热链——所有这些都有助于提升读取性能。它还可以通过释放死元组占用的存储空间来防止表膨胀。
清理通常配置为自动运行,但也可以手动运行。让我们尝试清理包含 1 个死元组的表。
postgres = # SELECT n_live_tup, n_dead_tup, relname FROM pg_stat_all_tables WHERE relname = 'store' ;
n_live_tup | n_dead_tup | relname
------------+------------+---------
1 | 1 | store ( 1 row )
--- 我们有 1 个死元组
--- 手动清理“store”表
postgres = # VACUUM store;
VACUUM
--- 我们最终得到 0 个死元组
postgres = # SELECT n_live_tup, n_dead_tup, relname FROM pg_stat_all_tables WHERE relname = 'store' ;
n_live_tup | n_dead_tup | relname
------------+------------+---------
1 | 0 |存储
(1 行)
postgres = #从heap_page_items(get_raw_page( 'store',0 ))中选择lp、t_ctid、t_xmin、t_xmax ; lp | t_ctid | t_xmin | t_xmax ----+--------+--------+ -------- 1 | | | 2 |(0,2)| 756 | 0 ( 2 行)
- 检查页面时我们只能看到一个元组,位于 lp 2,因为lp1 处的旧元组已被删除。
然而,清理表是有代价的。它会消耗 CPU 和内存,占用原本可以用于读写的资源,从而造成性能瓶颈。此外,清理所需的时间可能从几秒到几小时不等,具体取决于所需的清理量。
真空对于 至关重要Transaction Wraparound。您可以在这里阅读更多相关信息
https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND
对读取性能的影响
MVCC 不仅影响写入性能,还会影响读取性能。执行查询时,Postgres 需要确定哪个行版本有效,这会增加一些开销:
1. 行可见性检查
每次读取一行时,Postgres 都会检查其xmin和xmax值以确定它是否是最新版本。这会增加一些处理时间,尤其是在由于频繁更新而存在多个行版本的情况下。
2. 表膨胀
当更新操作创建新的行版本时,旧版本将保留在表中,直到VACUUM操作将其删除。随着时间的推移,这会增加表的大小并降低读取速度,因为 Postgres 可能需要扫描多个行版本才能找到有效的版本。
频繁VACUUM并AUTOVACUUM有助于减少膨胀并提高读取性能。
3. 写入密集型工作负载中的更新与插入
读取延迟可能会有所不同,具体取决于您是更新现有行还是插入新行:
- 插入——直接添加新行而不影响现有行。
- 更新——创建一个新的行版本并将旧版本标记为死版本,这会增加表的大小,并且需要在读取期间做更多的工作才能找到最新版本。
对于更新,Postgres 可能需要调整索引指针,因为每次更新都会创建一个新的元组。但是,如果更新符合HOT(仅堆元组)更新的条件,则意味着索引不需要立即更新,从而提高效率。
然而,热更新会在堆中创建元组链,这会增加的工作量VACUUM。Postgres 需要在读取期间跟踪链以查找最新的有效版本,并且一旦VACUUM删除旧版本,索引可能仍需要调整以反映最新状态。
频繁执行自动清理有助于清理死行并保持一致的性能。如果您在写入密集型工作负载中发现读取速度变慢,降低更新频率或调整数据模型可能会有所帮助。
这并不是建议你完全避免更新。但是,如果你在写入密集型工作负载下遇到更高的读取延迟,则值得考虑高更新频率是否是导致此问题的原因。优化数据模型以减少或避免过度更新,可以帮助缓解此问题并提高整体性能。
对于写入极其繁重的情况,针对高写入吞吐量进行优化的宽列存储(如 Cassandra 或 ScyllaDB)可以提供更好的性能。
对存储空间的影响
由于 MVCC,我们进行的更新越多,Postgres 消耗的磁盘空间就越多,而vacuum 负责回收该空间。
让我们用一个例子来演示一下,我将插入 100 万条记录并执行一些更新。我们还将查看每一步表占用的存储空间。
postgres = #截断store;
TRUNCATE TABLE
---
插入一百万条记录postgres = # DO $$
BEGIN
FOR i IN 1. .1000000 LOOP
INSERT INTO store (name, value )
VALUES ( 'Name_' || i, i * 10 );
END LOOP;
END $$;
DO
postgres = # SELECT COUNT ( * ) FROM store;
count
---------
1000000
( 1 row )
让我们看看这个表占用的空间。
postgres = # SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM
pg_catalog.pg_statio_user_tables
WHERE
relname = 'store' ;
table_name | total_size
------------+------------
store | 71 MB
( 1 行)
这 100 万条记录占用了大约 71 MB 的磁盘空间。让我们更新所有行并检查存储空间(我们仍然禁用了自动清理功能)。
postgres = #更新存储设置 值= 2000 ;
更新 1000000
postgres = # SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM
pg_catalog.pg_statio_user_tables
WHERE
relname = 'store' ;
table_name | total_size
------------+------------
store | 142 MB ( 1 行)
正如预期的那样,由于更新语句之后每行都有 2 个元组(1 个死元组和 1 个活元组),因此表的大小增加了一倍。
让我们来看看这张表占用的空间。
postgres = # VACUUM 存储;
VACUUM
postgres = # SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM
pg_catalog.pg_statio_user_tables
WHERE relname = 'store' ;
table_name | total_size
------------+------------
存储 | 142 MB
(1 行)
postgres = # SELECT n_live_tup,n_dead_tup,relname FROM pg_stat_all_tables WHERE relname = 'store' ;
n_live_tup | n_dead_tup | relname
------------+------------+---------
1000000 | 0 |存储
(1 行)
有趣的是,在清理表之后,存储空间仍然为 142 MB,但死元组的数量已降至零,证实清理操作已成功清理元组。
这是 Vacuum 的预期行为——它不会减少总存储空间,而是将释放的空间标记为可重用。这意味着任何新数据都将写入现有存储空间,而不是增加整体磁盘使用率。
但是,在某些情况下,这可能并不理想。如果您真的想回收磁盘空间,可以运行 VACUUM FULL 操作:
postgres = # VACUUM FULL存储;
VACUUM
postgres = # SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM
pg_catalog.pg_statio_user_tables
WHERE
relname = 'store';
table_name | total_size
------------+------------
store | 71 MB
(1 行)
在这个例子中,VACUUM FULL 回收了之前标记为可重复使用或未使用的物理磁盘空间,从而减少了整体存储大小。
结论
我们详细介绍了 MVCC 的工作原理以及真空过程对性能的重要性。
这篇文章中还有很多内容我没有涉及,我鼓励您阅读其中的一些主题(或者除非我决定在将来的某个时候撰写它们):
- 真空分析操作
- 重建索引操作
- 交易回溯调节
- 自动真空
要点:MVCC 允许 Postgres 高效地处理并发事务,但也带来了一些弊端。插入操作通常比更新操作读取/性能更快,因为更新操作会创建新的行版本,这会导致数据库膨胀,除非通过清理操作进行妥善管理。了解 MVCC 和 HOT 有助于您微调数据库以获得更佳性能。
#PG证书#PG考试#PostgreSQL培训#PostgreSQL考试#PostgreSQL认证