【PGCCC】Postgres MVCC 内部:更新与插入的隐性成本

为什么 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认证

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值