# 快照

LLMS 索引： [llms.txt](/llms.txt)

---

## 4.1 什么是快照？ {#41-什么是快照}

数据页可以包含同一行的多个版本，尽管每个事务最多只能看到其中一个版本。所有不同行的可见版本共同构成一个快照。快照仅包括在获取时已提交的当前数据，因此为这一特定时刻提供了一致的 (在 ACID 意义上) 数据视图。

为了确保隔离性，每个事务都使用其自己的快照。这意味着不同的事务可以看到在不同时间点获取的不同快照，但这些快照仍然是一致的。

在读已提交隔离级别下，每个语句开始时获取一个快照，并且仅在该语句期间内保持活跃状态。

在可重复读和可序列化隔离级别下，在事务内的第一条语句开始时获取一个快照，并且会保持活跃状态直到整个事务完成。

![4.1 什么是快照？：插图 1](/images/internal/zh/docs/chapter04/4-1.png)
{#zh-fig-4-1 num="4.1" .book-original-figure width=1526 height=500}

## 4.2 行版本可见性 {#42-行版本可见性}

快照并不是所有所需元组的物理拷贝。相反，它由几个数字定义，而元组的可见性则由特定规则决定。

元组可见性由元组头中的 xmin 和 xmax 字段 (即执行插入和删除的事务 ID) 和相应的提示位定义。由于 xmin–xmax 区间不相交，因此在任何快照中，每一行仅由其版本中的一个所表示。

确切的可见性规则十分复杂 [^zh-04-snapshots-1]，因为考虑到了各种不同的场景和极端情况。非常粗略地，我们可以这样描述它们：一个元组在包括 xmin 事务更改但不包括 xmax 事务更改的快照中是可见的 (换句话说，该元组已经出现并且尚未被删除)。

如果事务在快照创建之前提交，那么事务更改在快照中是可见的。作为一个例外，事务可以看到自己未提交的更改。如果事务被中止，其更改不会在任何快照中可见。

让我们看一个简单的例子。在此图中，线段代表事务 (从它们的开始时间到提交时间)：

![4.2 行版本可见性：插图 2](/images/internal/zh/docs/chapter04/4-2.png)
{#zh-fig-4-2 num="4.2" .book-original-figure width=1536 height=464}

此处，应用于事务的可见性规则如下：

- 事务 2 在快照创建之前已提交，因此其更改是可见的。

- 事务 1 在快照创建时仍处于活跃状态，因此其更改不可见。

- 事务 3 是在快照创建后启动的，因此它的更改也不可见 (无论此事务完成与否都没有区别)。

## 4.3 快照结构 {#43-快照结构}

不幸的是，前面的示例与 PostgreSQL 实际看到的情况并不相符 [^zh-04-snapshots-2]。问题在于，系统不知道事务何时提交。仅知道它们是何时开始的 (这一时刻由事务 ID 定义)，事务何时完成的未在任何地方记录。

> 如果启用了 <em>track\_commit\_timestamp</em><span class="book-margin-note" aria-label="原书旁注">off</span> 参数，可以跟踪事务的提交时间 [^zh-04-snapshots-3]，但它们不会以任何方式参与可见性检查 (尽管出于其他目的跟踪它们仍然是有用的，例如，应用于外部复制解决方案)。
>
>
>
>
> 此外，PostgreSQL 总是在相应的 WAL 条目中记录提交和回滚的时间，但这些信息仅用于时间点恢复。

我们只能了解到事务的当前状态。此信息可以在服务器的共享内存中获取：ProcArray 结构包含所有活跃会话及其事务的列表。一旦事务完成，就无法确定它在快照创建时是否处于活跃状态。

因此创建快照仅记录获取的时刻是不够的：还需要收集在那一时刻所有事务的状态。否则，之后将无法确定哪些元组在快照中必须可见，哪些必须被排除。

让我们看一下系统在获取快照时以及一段时间后可用的信息，白色圆圈表示活跃事务，黑色圆圈表示已完成的事务：

![4.3 快照结构：插图 3](/images/internal/zh/docs/chapter04/4-3.png)
{#zh-fig-4-3 num="4.3" .book-original-figure width=1546 height=416}

假设我们不知道在获取快照时第一个事务仍在执行中，而第三个事务尚未开始。那么看起来它们就像第二个事务 (在那个时刻已提交) 一样，无法将它们过滤掉。

因此，即使所有需要的元组都存在于堆页面中，PostgreSQL 也无法创建一个快照来显示过去某个任意时间点的数据一致状态。所以，PostgreSQL 无法实现回溯查询 (有时也称为时间或闪回查询)。

> 有趣的是，该功能被宣布为 Postgres 的目标之一，并在初期就实现了，但是当项目支持转交给社区时，它被从数据库系统中移除了 [^zh-04-snapshots-4]。

因此，快照由创建时保存的几个值所组成 [^zh-04-snapshots-5]：

<strong>xmin</strong> 是快照的下边界，由最老的活跃事务 ID 表示。

所有 ID 更小的事务要么已提交 (因此它们的更改包含在快照中)，要么已中止 (因此它们的更改会被忽略)。

<strong>xmax</strong> 是快照的上边界，由最新已提交的事务 ID 加 1 所表示。上边界定义了获取快照的时刻。

所有 ID 大于或等于 xmax 的事务要么仍在运行中，要么不存在，因此它们的更改不可见。

<strong>xip\_list</strong> 是除了虚拟事务之外的所有活跃事务 ID 的列表，虚拟事务不会以任何方式影响可见性。

快照还包括其他几个参数，但此处我们将忽略它们。

以图形表示，快照可以表示为一个矩形，包括从 xmin 到 xmax 的事务。

![4.3 快照结构：插图 4](/images/internal/zh/docs/chapter04/4-4.png)
{#zh-fig-4-4 num="4.4" .book-original-figure width=1528 height=434}

为了理解快照是如何定义可见性规则的，我们将在 accounts 表上复现上述场景。

```text
=> TRUNCATE TABLE accounts;
```




第一个事务向表中插入第一行，并保持开启状态：

```text
=> BEGIN;
=> INSERT INTO accounts VALUES (1, 'alice', 1000.00);
=> SELECT pg_current_xact_id();
 pg_current_xact_id
−−−−−−−−−−−−−−−−−−−−
                790
(1 row)
```




第二个事务插入第二行，并立即提交此更改：

> ```text
> => BEGIN;
> => INSERT INTO accounts VALUES (2, 'bob', 100.00);
> => SELECT pg_current_xact_id();
>  pg_current_xact_id
> −−−−−−−−−−−−−−−−−−−−
>                 791
> (1 row)
> => COMMIT;
> ```

此时，让我们在另一个会话中创建一个新的快照。 为此，我们可以简单地运行任何查询，但我们将使用一个特殊函数来立即查看此快照：

> > ```text
> > => BEGIN ISOLATION LEVEL REPEATABLE READ;
> > => -- txid_current_snapshot() before v.13
> > SELECT pg_current_snapshot();
> >  pg_current_snapshot
> > −−−−−−−−−−−−−−−−−−−−−
> >  790:792:790
> > (1 row)
> > ```

此函数显示了快照的组成，以冒号分隔：xmin、xmax 和 xip\_list (活跃事务列表；在这个特定案例中，它由单个条目组成)。

一旦获取了快照之后，提交第一个事务：

```text
=> COMMIT;
```




第三个事务是在快照创建后开始的。它修改了第二行，所以出现了一个新的元组：

> ```text
> => BEGIN;
> => UPDATE accounts SET amount = amount + 100 WHERE id = 2;
> => SELECT pg_current_xact_id();
>  pg_current_xact_id
> −−−−−−−−−−−−−−−−−−−−
>                 792
> (1 row)
> => COMMIT;
> ```

我们的快照只看到一个元组：

> > ```text
> > => SELECT ctid, * FROM accounts;
> >   ctid | id | client | amount
> > −−−−−−−+−−−−+−−−−−−−−+−−−−−−−−
> >  (0,2) | 2  | bob    | 100.00
> > (1 row)
> > ```

但是表中包含了三个元组：

> > ```text
> > => SELECT * FROM heap_page('accounts',0);
> >   ctid | state  | xmin  | xmax
> > −−−−−−−+−−−−−−−−+−−−−−−−+−−−−−−−
> >  (0,1) | normal | 790 c | 0 a
> >  (0,2) | normal | 791 c | 792 c
> >  (0,3) | normal | 792 c | 0 a
> > (3 rows)
> > ```

那么 PostgreSQL 是如何选择要显示哪些版本的呢？ 根据上述规则，仅当满足以下条件的已提交事务执行的更改，才会被包含在快照中：

- 如果 xid &lt; xmin，那么更改会无条件显示 (就像创建 accounts 表的事务一样)。

- 如果 xmin ⩽ xid &lt; xmax，那么仅当相应的事务 ID 不在 xip\_list 中时，更改才会被显示。

第一行 (0,1) 是不可见的，因为插入它的事务出现在了 xip\_list 中 (即使该事务落在了快照范围内)。

第二行的最新版本 (0,3) 是不可见的，因为相应的事务 ID 超过了快照的上边界。

但是第二行的第一个版本 (0,2) 是可见的：插入行的事务落在了快照范围内，并且没有出现在 xip\_list 中 (插入是可见的)，而删除行的事务，其 ID 超过了快照的上边界 (删除是不可见的)。

> > ```text
> > => COMMIT;
> > ```

## 4.4 事务自身更改的可见性 {#44-事务自身更改的可见性}

在定义事务自身更改的可见性规则时，事情变得更加复杂：在某些情况下，只有部分更改必须是可见的。例如，无论隔离级别如何，在特定时间点打开的游标不能看到之后发生的任何更改。

为了解决这类情况，元组头提供了一个特殊字段 (cmin 和 cmax 伪列)，用于显示事务内操作的序号。cmin 列用于标识插入，而 cmax 用于删除操作。为了节省空间，这些值存储在元组头的单个字段中，而不是存储在两个不同的字段中。考虑到同一行几乎永远不会在单个事务中既被插入又被删除。(如果确实发生了，PostgreSQL 会在这个字段中写入一个特殊的 combo 标识符，在这种情况下，实际的 cmin 和 cmax 值由后端进程存储 [^zh-04-snapshots-6])。

作为示例，让我们启动一个事务并在表中插入一行：

```text
=> BEGIN;
=> INSERT INTO accounts VALUES (3, 'charlie', 100.00);
=> SELECT pg_current_xact_id();
 pg_current_xact_id
−−−−−−−−−−−−−−−−−−−−
                793
(1 row)
```




然后打开一个游标，用于执行返回表中行数的查询：

```text
=> DECLARE c CURSOR FOR SELECT count(*) FROM accounts;
```




再插入一行：

```text
=> INSERT INTO accounts VALUES (4, 'charlie', 200.00);
```




现在用另一列来扩展输出，以显示插入行的 cmin 值 (对于其他行没有意义)：

```text
=> SELECT xmin, CASE WHEN xmin = 793 THEN cmin END cmin, *
FROM accounts;
 xmin | cmin | id | client  | amount
−−−−−−+−−−−−−+−−−−+−−−−−−−−−+−−−−−−−−−
  790 |      |  1 | alice 	| 1000.00
  792 |      |  2 | bob     |  200.00
  793 |   0	 |  3 | charlie |  100.00
  793 |   1  |  4 | charlie |  200.00
(4 rows)
```




游标查询只获取到三行；在游标已经打开时插入的行未包含在快照中，因为它不满足 cmin &lt; 1 的条件：

```text
=> FETCH c;
 count
−−−−−−−
     3
(1 row)
```




当然，这个 cmin 数字也存储在快照中，但无法使用任何 SQL 手段显示它。

## 4.5 事务视界 {#45-事务视界}

如前文所述，快照的下边界由 xmin 表示，xmin 是快照创建时处于活跃状态的最老的事务 ID。这个值非常重要，因为它定义了使用这个快照的事务视界。

如果一个事务没有活跃的快照 (例如，在读已提交隔离级别的语句执行之间)，那么其视界由它自己的 ID 定义(如果已分配)。

所有超出视界的事务 (那些 xid &lt; xmin 的事务) 都保证已提交。这意味着，一个事务只能看到其视界之外的当前行版本。

> 如你所料，这个术语的灵感来自物理学中的事件视界概念。

PostgreSQL 跟踪所有进程的当前视界；事务可以在 pg\_stat\_activity 表中看到它们自己的视界：

```text
=> BEGIN;
=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
−−−−−−−−−−−−−−
          793
(1 row)
```




虚拟事务没有真正的 ID，但它们仍然像常规事务一样使用快照，因此它们有自己的视界。唯一例外是没有活跃快照的虚拟事务：视界的概念对于它们来说毫无意义，当涉及到快照和可见性时，它们对系统是完全&quot;透明的&quot; (尽管 pg\_stat\_activity.backend\_xmin 中仍可能包含旧快照的 xmin)。

我们也可以用类似的方式去定义数据库视界。为此，我们应当获取这个数据库中所有事务的视界，并选择最远的一个，其 xmin 最老 [^zh-04-snapshots-7]。在这个视界之外，过期的堆元组将永远不会对这个数据库中的任何事务可见。这样的元组便可以被 vacuum 安全地清理掉 — 这正是从实际角度来看，为什么视界的概念如此重要的原因。

![4.5 事务视界：插图 5](/images/internal/zh/docs/chapter04/4-5.png)
{#zh-fig-4-5 num="4.5" .book-original-figure width=1532 height=874}

让我们总结一下：

- 如果处于可重复读或可序列化隔离级别下的事务 (无论是真实的还是虚拟的) 运行了很长时间，它会保持数据库视界并推迟清理。

- 处于读已提交隔离级别下的真实事务以相同的方式保持数据库视界，即使它没有执行任何操作符 (处于 “idle in transaction” 状态)。

- 处于读已提交隔离级别下的虚拟事务仅在执行操作符时才保持视界。

整个数据库只有一个视界，因此如果它被一个事务持有，那么这个视界内的任何数据都无法进行清理 — 即使这个事务没有访问这些数据。

> 集簇范围的系统目录表有一个单独的视界，该视界考虑了所有数据库中的所有事务。相反，临时表不需要关注除当前进程正在执行的事务之外的任何事务。

让我们回到当前的实验。第一个会话的活跃事务仍然持有数据库视界；我们可以通过增加事务计数器来看到这一点：

> ```text
> => SELECT pg_current_xact_id();
>  pg_current_xact_id
> −−−−−−−−−−−−−−−−−−−−
>                 794
> (1 row)
> ```

```text
=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
−−−−−−−−−−−−−−
          793
(1 row)
```




只有当这个事务完成时，视界才会向前推动，过期的元组才能被清理掉：

```text
=> COMMIT;
=> SELECT backend_xmin FROM pg_stat_activity
WHERE pid = pg_backend_pid();
 backend_xmin
−−−−−−−−−−−−−−
          795
(1 row)
```




在理想情况下，你应该避免将长事务与频繁更新 (产生新的行版本) 相结合，因为这会导致表和索引膨胀。

## 4.6 系统目录快照 {#46-系统目录快照}

尽管系统目录由常规表组成，但它们不能通过事务或操作符使用的快照来访问。快照必须足够&quot;新鲜&quot;以包含所有最新的更改，否则事务可能会看到过时的列定义或错过新添加的完整性约束。

此处是一个简单的例子：

```text
=> BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

=> SELECT 1; -- a snapshot for the transaction is taken
```




> ```text
> => ALTER TABLE accounts
>   ALTER amount SET NOT NULL;
> ```

```text
=> INSERT INTO accounts(client, amount)
VALUES ('alice', NULL);
ERROR: null value in column "amount" of relation "accounts"
violates not−null constraint
DETAIL: Failing row contains (1, alice, null).

=> ROLLBACK;
```




快照创建之后出现的完整性约束对 INSERT 命令是可见的。看起来这种行为打破了隔离性，但是如果插入的事务在 ALTER TABLE 命令之前就访问了 accounts 表，那么后者就会被阻塞，直至该事务完成。

通常，服务器的行为就像为每个系统目录查询创建了一个单独的快照。但是实现当然要复杂得多 [^zh-04-snapshots-8]，因为频繁创建快照会对性能产生负面影响；此外，许多系统目录对象都被缓存了，这也是必须考虑的。

## 4.7 导出快照 {#47-导出快照}

在某些情况下，必须确保并发事务无论如何都能看到同一个快照。例如，如果 pg\_dump 工具以并行模式运行，所有的进程必须看到相同的数据库状态，以产生一致的备份。

我们不能仅仅因为事务是&quot;同时&quot;启动的，就假设快照也是相同的。为了确保所有事务看到相同的数据，我们必须使用快照导出机制。

pg\_export\_snapshot 函数返回一个快照 ID，这个 ID 可以传递给另一个事务 (在数据库系统之外)：

```text
=> BEGIN ISOLATION LEVEL REPEATABLE READ;
=> SELECT count(*) FROM accounts;
 count
−−−−−−−
     4
(1 row)

=> SELECT pg_export_snapshot();
 pg_export_snapshot
−−−−−−−−−−−−−−−−−−−−−
 00000004−0000006E−1
(1 row)
```




在执行第一个语句之前，另一个事务可以通过运行 SET TRANSACTION SNAPSHOT 命令来导入快照。隔离级别必须设置为可重复读或可序列化，因为在读已提交级别下，操作符使用它们自己的快照：

> ```text
> => DELETE FROM accounts;
> => BEGIN ISOLATION LEVEL REPEATABLE READ;
> => SET TRANSACTION SNAPSHOT '00000004-0000006E-1';
> ```

现在第二个事务将使用第一个事务的快照，因此，它将看到四行 (而不是零)：

> ```text
> => SELECT count(*) FROM accounts;
>  count
> −−−−−−−
>   	 4
> (1 row)
> ```

显然，在快照导出后，第二个事务不会看到第一个事务所做的任何更改 (反之亦然)：常规的可见性规则仍然适用。

导出快照的生命周期与导出事务的生命周期相同。

> ```text
> => COMMIT;
> ```

```text
=> COMMIT;
```




[^zh-04-snapshots-1]: backend/access/heap/heapam\_visibility.c

[^zh-04-snapshots-2]: include/utils/snapshot.h  
    backend/utils/time/snapmgr.c

[^zh-04-snapshots-3]: backend/access/transam/commit\_ts.c

[^zh-04-snapshots-4]: <em>Joseph M. Hellerstein</em>, Looking Back at Postgres. [https://arxiv.org/pdf/1901.01973.pdf](<https://arxiv.org/pdf/1901.01973.pdf>)

[^zh-04-snapshots-5]: backend/storage/ipc/procarray.c, GetSnapshotData function

[^zh-04-snapshots-6]: backend/utils/time/combocid.c

[^zh-04-snapshots-7]: backend/storage/ipc/procarray.c, ComputeXidHorizons function

[^zh-04-snapshots-8]: backend/utils/time/snapmgr.c, GetCatalogSnapshot function
