fix ch7-transaction: repeatable read

This commit is contained in:
AlphaWang 2022-07-30 23:45:32 +08:00
parent e38874da61
commit 85f2242841

24
ch7.md
View File

@ -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
我们来更详细地讨论这两个保证。
@ -262,7 +262,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
通过防止脏写,这个隔离级别避免了一些并发问题:
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图 7-5](img/fig7-5.png)以一个二手车销售网站为例Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在 [图 7-5](img/fig7-5.png) 的情况下,销售是属于 Bob 的因为他成功更新了商品列表但发票却寄送给了Alice因为她成功更新了发票表。读已提交会防止这样的事故。
- 如果事务更新多个对象,脏写会导致不好的结果。例如,考虑 [图 7-5](img/fig7-5.png)以一个二手车销售网站为例Alice 和 Bob 两个人同时试图购买同一辆车。购买汽车需要两次数据库写入:网站上的商品列表需要更新,以反映买家的购买,销售发票需要发送给买家。在 [图 7-5](img/fig7-5.png) 的情况下,销售是属于 Bob 的(因为他成功更新了商品列表),但发票却寄送给了 Alice因为她成功更新了发票表。读已提交会防止这样的事故。
- 但是,读已提交并不能防止 [图 7-1](img/fig7-1.png) 中两个计数器增量之间的竞争状态。在这种情况下,第二次写入发生在第一个事务提交后,所以它不是一个脏写。这仍然是不正确的,但是出于不同的原因,在 “[防止更新丢失](#防止丢失更新)” 中将讨论如何使这种计数器增量安全。
![](img/fig7-5.png)
@ -285,7 +285,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
### 快照隔离和可重复读
如果只从表面上看读已提交隔离级别你就认为它完成了事务所需的一切,这是情有可原的。它允许 **中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
如果只从表面上看读已提交隔离级别可能就认为它完成了事务所需的一切,这是情有可原的。它允许 **中止**(原子性的要求);它防止读取不完整的事务结果,并且防止并发写入造成的混乱。事实上这些功能非常有用,比起没有事务的系统来,可以提供更多的保证。
但是在使用此隔离级别时,仍然有很多地方可能会产生并发错误。例如 [图 7-6](img/fig7-6.png) 说明了读已提交时可能发生的问题。
@ -293,9 +293,9 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
**图 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#负载偏斜与热点消除)”),而这里偏差意味着异常的时序。
@ -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 溢出(回卷)不会影响到数据。
@ -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 树;它们只能创建新的树根。但这种方法也需要一个负责压缩和垃圾收集的后台进程。
#### 可重复读与命名混淆
@ -384,7 +384,7 @@ SELECT COUNT*FROM emails WHERE recipient_id = 2 AND unread_flag = true
如果应用从数据库中读取一些值,修改它并写回修改的值(读取 - 修改 - 写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改(有时会说后面写入 **狠揍clobber** 了前面的写入)这种模式发生在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 将本地修改写入一个复杂值中:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
这是一个普遍的问题,所以已经开发了各种解决方案。
@ -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#自动冲突解决)”。
@ -440,7 +440,7 @@ COMMIT;
在不提供事务的数据库中,有时会发现一种原子操作:**比较并设置**CAS, 即 Compare And Set先前在 “[单对象写入](#单对象写入)” 中提到)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取 - 修改 - 写入序列。
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新:
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容未发生改变时,才会更新成功
```sql
-- 根据数据库的实现情况,这可能安全也可能不安全