Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix transaction: repeatable read #255

Merged
merged 2 commits into from
Jul 31, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions ch7.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
最基本的事务隔离级别是 **读已提交(Read Committed)**[^v],它提供了两个保证:

1. 从数据库读时,只能看到已提交的数据(没有 **脏读**,即 dirty reads)。
2. 写入数据库时,只会覆盖已经提交的数据(没有 **脏写**,即 dirty writes)。
2. 写入数据库时,只会覆盖已提交的数据(没有 **脏写**,即 dirty writes)。

我们来更详细地讨论这两个保证。

Expand Down Expand Up @@ -285,17 +285,17 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true

### 快照隔离和可重复读

如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,这是情有可原的。它允许 **中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
如果只从表面上看读已提交隔离级别,你可能就认为它完成了事务所需的一切,这是情有可原的。它允许 **中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。

但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如 [图 7-6](img/fig7-6.png) 说明了读已提交时可能发生的问题。

![](img/fig7-6.png)

**图 7-6 读取偏差:Alice 观察数据库处于不一致的状态**

爱丽丝在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会在收到付款之前先看到一个账户的余额(收款账户,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户,新余额为 400 美元)。对爱丽丝来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。
Alice 在银行有 1000 美元的储蓄,分为两个账户,每个 500 美元。现在有一笔事务从她的一个账户转移了 100 美元到另一个账户。如果她非常不幸地在事务处理的过程中查看其账户余额列表,她可能会在收到付款之前先看到一个账户的余额(收款账户,余额仍为 500 美元),在发出转账之后再看到另一个账户的余额(付款账户,新余额为 400 美元)。对 Alice 来说,现在她的账户似乎总共只有 900 美元 —— 看起来有 100 美元已经凭空消失了。

这种异常被称为 **不可重复读(nonrepeatable read)** 或 **读取偏差(read skew)**:如果 Alice 在事务结束时再次读取账户 1 的余额,她将看到与她之前的查询中看到的不同的值(600 美元)。在读已提交的隔离条件下,**不可重复读** 被认为是可接受的:Alice 看到的帐户余额时确实在阅读时已经提交了
这种异常被称为 **不可重复读(nonrepeatable read)** 或 **读取偏差(read skew)**:如果 Alice 在事务结束时再次读取账户 1 的余额,她将看到与她之前的查询中看到的不同的值(600 美元)。在读已提交的隔离条件下,**不可重复读** 被认为是可接受的:Alice 看到的帐户余额确实在阅读时已经提交了

> 不幸的是,术语 **偏差(skew)** 这个词是过载的:以前使用它是因为热点的不平衡工作量(请参阅 “[负载偏斜与热点消除](ch6.md#负载偏斜与热点消除)”),而这里偏差意味着异常的时序。

Expand All @@ -317,13 +317,13 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true

#### 实现快照隔离

与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅 “[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作。且两者间没有任何锁定争用
与读取提交的隔离类似,快照隔离的实现通常使用写锁来防止脏写(请参阅 “[读已提交](#读已提交)”),这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取则不需要加锁。从性能的角度来看,快照隔离的一个关键原则是:**读不阻塞写,写不阻塞读**。这允许数据库在处理一致性快照上的长时间查询时,可以正常地同时处理写入操作,且两者间没有任何锁争用

为了实现快照隔离,数据库使用了我们看到的用于防止 [图 7-4](img/fig7-4.png) 中的脏读的机制的一般化。数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它同时维护着单个对象的多个版本,所以这种技术被称为 **多版本并发控制(MVCC, multi-version concurrency control)**。

如果一个数据库只需要提供 **读已提交** 的隔离级别,而不提供 **快照隔离**,那么保留一个对象的两个版本就足够了:提交的版本和被覆盖但尚未提交的版本。支持快照隔离的存储引擎通常也使用 MVCC 来实现 **读已提交** 隔离级别。一种典型的方法是 **读已提交** 为每个查询使用单独的快照,而 **快照隔离** 对整个事务使用相同的快照。
如果一个数据库只需要提供 **读已提交** 的隔离级别,而不提供 **快照隔离**,那么保留一个对象的两个版本就足够了:已提交的版本和被覆盖但尚未提交的版本。不过支持快照隔离的存储引擎通常也使用 MVCC 来实现 **读已提交** 隔离级别。一种典型的方法是 **读已提交** 为每个查询使用单独的快照,而 **快照隔离** 对整个事务使用相同的快照。

[图 7-7](img/fig7-7.png) 说明了如何在 PostgreSQL 中实现基于 MVCC 的快照隔离【31】(其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长 [^vii] 的事务 ID(`txid`)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。
[图 7-7](img/fig7-7.png) 说明了 PostgreSQL 如何实现基于 MVCC 的快照隔离【31】(其他实现类似)。当一个事务开始时,它被赋予一个唯一的,永远增长 [^vii] 的事务 ID(`txid`)。每当事务向数据库写入任何内容时,它所写入的数据都会被标记上写入者的事务 ID。

[^vii]: 事实上,事务 ID 是 32 位整数,所以大约会在 40 亿次事务之后溢出。 PostgreSQL 的 Vacuum 过程会清理老旧的事务 ID,确保事务 ID 溢出(回卷)不会影响到数据。

Expand Down Expand Up @@ -363,7 +363,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true

在 CouchDB、Datomic 和 LMDB 中使用另一种方法。虽然它们也使用 [B 树](ch3.md#B树),但它们使用的是一种 **仅追加 / 写时拷贝(append-only/copy-on-write)** 的变体,它们在更新时不覆盖树的页面,而为每个修改页面创建一份副本。从父页面直到树根都会级联更新,以指向它们子页面的新版本。任何不受写入影响的页面都不需要被复制,并且保持不变【33,34,35】。

使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一颗新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
使用仅追加的 B 树,每个写入事务(或一批事务)都会创建一棵新的 B 树,当创建时,从该特定树根生长的树就是数据库的一个一致性快照。没必要根据事务 ID 过滤掉对象,因为后续写入不能修改现有的 B 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。

#### 可重复读与命名混淆

Expand All @@ -384,7 +384,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
如果应用从数据库中读取一些值,修改它并写回修改的值(读取 - 修改 - 写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入 **狠揍(clobber)** 了前面的写入)这种模式发生在各种不同的情况下:

- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 将本地修改写入一个复杂值中:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。

这是一个普遍的问题,所以已经开发了各种解决方案。
Expand All @@ -397,7 +397,7 @@ SELECT COUNT(*)FROM emails WHERE recipient_id = 2 AND unread_flag = true
UPDATE counters SET value = value + 1 WHERE key = 'foo';
```

类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如维基页面的更新涉及到任意文本编辑 [^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如 wiki 页面的更新涉及到任意文本编辑 [^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。

[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。请参阅 “[自动冲突解决](ch5.md#自动冲突解决)”。

Expand Down Expand Up @@ -440,7 +440,7 @@ COMMIT;

在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置**(CAS, 即 Compare And Set,先前在 “[单对象写入](#单对象写入)” 中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列。

例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑后页面内容未发生改变时,才会更新成功

```sql
-- 根据数据库的实现情况,这可能安全也可能不安全
Expand Down