mirror of
https://github.com/DistSysCorp/ddia.git
synced 2024-12-25 20:30:39 +08:00
Update ch07.md
This commit is contained in:
parent
d8616bf4c0
commit
b134de6473
37
ch07.md
37
ch07.md
@ -70,7 +70,7 @@
|
||||
|
||||
设有一个计数器,且数据库没有内置原子的自增操作,有两个用户,各自读取当前值,加 1 后写回。如图,期望计数器由 42 变为 44,但由于并发问题,最终变成了 43 。
|
||||
|
||||
ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务都可以认为自己是系统中唯一正在与运行的事务,因此传统上,教科书将事务隔离形式称为:**可串行化(Serializability)**。即,如果所有事务都串行执行,则任意时刻必然只有一个事务在执行,从而在根本上消除任何并发问题。
|
||||
ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务都可以认为自己是系统中唯一正在运行的事务,因此传统上,教科书将事务隔离形式称为:**可串行化(Serializability)**。即,如果所有事务都串行执行,则任意时刻必然只有一个事务在执行,从而在根本上消除任何并发问题。
|
||||
|
||||
但在实践中,很少用这么强的隔离性,实际上隔离性强弱类似于一个光谱,数据库系统提供商一般会实现其中几个,用户可以根据业务情况在隔离性和性能间进行选择。
|
||||
|
||||
@ -99,7 +99,7 @@ ACID 中隔离性是指,每个事务的执行是互相隔离的,每个事务
|
||||
|
||||
## 单对象和多对象操作
|
||||
|
||||
总结来说,在 ACID 中,原子性和隔离性是数据库对用户进行多个写入时需要提供的保证,并且他们通常假设一个事务中会同时修改多个对象(rows、documents 和 records )。相比**单对象事务**(single-object transaction),这种多**对象事务**(multi-objects transaction)是一种更强的保证,且更常用,因为通常多个写入不会值针对单个对象。
|
||||
总结来说,在 ACID 中,原子性和隔离性是数据库对用户进行多个写入时需要提供的保证,并且它们通常假设一个事务中会同时修改多个对象(rows、documents 和 records )。相比**单对象事务**(single-object transaction),这种多**对象事务**(multi-objects transaction)是一种更强的保证,且更常用,因为通常多个写入不会只针对单个对象。
|
||||
|
||||
设有电子邮件情景,邮箱首页需要如下语句来展示未读邮件数:
|
||||
|
||||
@ -126,7 +126,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
实际中基本上使用第二种方法。
|
||||
|
||||
有一些非关系型数据库,虽然提供 Batch 操作接口,但他们并不一定有事务语义,即可能有些对象成功,另外一些对象操作却失败。
|
||||
有一些非关系型数据库,虽然提供 Batch 操作接口,但它们并不一定有事务语义,即可能有些对象成功,另外一些对象操作却失败。
|
||||
|
||||
### 单对象写入
|
||||
|
||||
@ -184,7 +184,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
## 读已提交
|
||||
|
||||
性能最好的隔离级别就是不上任何锁,但会存在**脏读**和**脏写**的问题。为了避免脏写,可以给要更改的对象加长时写锁,但读数据时并不加锁,此时的隔离级别称为**读未提交**(RU,Read Uncommitted)。但此时仍然会有脏读,为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别是是**读已提交**(RC,Read Committed),他提供了两个保证:
|
||||
性能最好的隔离级别就是不上任何锁,但会存在**脏读**和**脏写**的问题。为了避免脏写,可以给要更改的对象加长时写锁,但读数据时并不加锁,此时的隔离级别称为**读未提交**(RU,Read Uncommitted)。但此时仍然会有脏读,为了避免脏读,可以对要读取的对象加短时读锁,此时的隔离级别是**读已提交**(RC,Read Committed),他提供了两个保证:
|
||||
|
||||
1. 从数据库读取时,只能读到已经提交的数据(即没有脏读,no dirty reads)
|
||||
2. 往数据库写入时,只能覆盖已经提交的数据(即没有脏写,no dirty writes)
|
||||
@ -233,7 +233,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
![Untitled](img/ch07-fig06.png)
|
||||
|
||||
这种异常被称为**不可重复读**(non-repeatable read)或者**读倾斜**(read skew,skew 有点被过度使用)。在读已提交的隔离级别下,不可重复读时允许的,如上述例子,每次读取到的都是已提交的内容。
|
||||
这种异常被称为**不可重复读**(non-repeatable read)或者**读倾斜**(read skew,skew 有点被过度使用)。读已提交的隔离级别允许出现不可重复读问题,如上述例子,每次读取到的都是已提交的内容。
|
||||
|
||||
例子中的不一致情况,只是暂时的。但在某些情况下,这种暂时的不一致也是不可接受的:
|
||||
|
||||
@ -246,7 +246,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
|
||||
### 快照隔离的实现
|
||||
|
||||
和读已提交一样,快照隔离也使用加锁的方式来防止脏写,但在进行读取不能用使用锁。快照隔离的一个关键原则是“读不阻塞写,写不阻塞读”,从而允许用户在进行长时间查询时,不影响新的写入。
|
||||
和读已提交一样,快照隔离也使用加锁的方式来防止脏写,但在进行读取不使用锁。快照隔离的一个关键原则是“读不阻塞写,写不阻塞读”,从而允许用户在进行长时间查询时,不影响新的写入。
|
||||
|
||||
为了实现快照隔离,保证读不阻塞写,且避免脏读,数据库需要对同一个对象保留多个已提交的版本,我们称之为**多版本并发控制**(**MVCC,multi-version concurrency control**)。
|
||||
|
||||
@ -255,7 +255,7 @@ SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
|
||||
具体来说,使用 MVCC 流派,也可以实现读未提交、读已提交、快照隔离、可串行化等隔离级别。
|
||||
|
||||
1. **读已提交**在查询语句粒度使用单独的快照,快照粒度更小,因此性能更好。
|
||||
2. **快照隔离**在事务粒度使用相同的快照(主要解决**可重复读**)。
|
||||
2. **快照隔离**在事务粒度使用相同的快照(主要解决**不可重复读**问题)。
|
||||
|
||||
MVCC 的基本要点为:
|
||||
|
||||
@ -272,7 +272,7 @@ MVCC 的基本要点为:
|
||||
|
||||
- 个人认为不使用 delete by 也能达到标记删除的效果?
|
||||
|
||||
新的版本数据存在后,自动就使得老版本不可见。之后,只要确定没有事务正在访问老版本数据,即可进行 gc。如何判定没有事务访问了,min(current tx) > latest version 即可。
|
||||
新的版本数据存在后,自动就使得老版本不可见。之后,只要确定没有事务正在访问老版本数据,即可进行 gc。通过min(current tx) > latest version 即可判定没有事务访问了。
|
||||
|
||||
|
||||
### 可见性规则
|
||||
@ -286,7 +286,7 @@ MVCC 的基本要点为:
|
||||
3. 具有较晚事务 ID 的事务所做的任何写入都会被忽略。
|
||||
4. 剩余其他的数据,对此事务都可见。
|
||||
|
||||
如果事务 txid 一致是自增的,则可以理解为,对于 txid = x 的事务来说:
|
||||
如果事务 txid 是严格自增的,则可以理解为,对于 txid = x 的事务来说:
|
||||
|
||||
1. 对于所有 txid < x 的事务,如果已经中止或**正在进行**,则其所写数据不可见。
|
||||
2. 对于所有 txid > x 的事务,所写数据皆不可见。
|
||||
@ -306,7 +306,7 @@ MVCC 的基本要点为:
|
||||
|
||||
在实践中,有很多优化。如 PostgreSQL 的一个优化是,如果某个对象更新前后的数据都在一个物理页中,则对应的索引指向可以不用更新。
|
||||
|
||||
CouchDB、Datomic 和 LMDB 中使用一种 **仅追加 / 写时拷贝(append-only/copy-on-write)**的 B 树变体,是一种**多版本技术**的变体。[boltdb](https://www.qtmuniao.com/2020/11/29/bolt-data-organised/) 参考了 LMDB,也可以归为此类,此类 B 族树每次修改,都会引起叶子节点(所有数据都会落到叶子节点)到根节点的一条路径的全部修改(叶子节点变了,其父节点内容——指针也要修改,从而引起级联修改),如果发生引起分裂或者合并,这会引起更大范围的更新和修改。
|
||||
CouchDB、Datomic 和 LMDB 中使用一种 **仅追加 / 写时拷贝(append-only/copy-on-write)**的 B 树变体,是一种**多版本技术**的变体。[boltdb](https://www.qtmuniao.com/2020/11/29/bolt-data-organised/) 参考了 LMDB,也可以归为此类,此类 B 族树每次修改,都会引起叶子节点(所有数据都会落到叶子节点)到根节点的一条路径的全部修改(叶子节点变了,其父节点内容——指针也要修改,从而引起级联修改),如果发生节点的分裂或合并,会引起更大范围的更新。
|
||||
|
||||
这种方式在更新时不会覆盖老的页,每个数据修改都会新生成一个树根,每个树根所代表的树可以视作一个版本的快照。使用某个树根就相当于使用某个版本快照,其所能访问到的数据都属于同一个版本,而无须再进行版本过滤。当然, 这类系统也需要后台常驻的 compaction 和 GC。
|
||||
|
||||
@ -316,7 +316,7 @@ CouchDB、Datomic 和 LMDB 中使用一种 **仅追加 / 写时拷贝(append-o
|
||||
|
||||
因此,虽然快照隔离级别很有用,尤其是只读事务,但很多数据库虽然实现了快照隔离,但却另有称谓。比如 Oracle 将 SI 称为 **可串行化(Serializable)**, PostgreSQL 和 MySQL 将 SI 称为 **可重复读(repeatable read)**。因为这样可以符合 SQL 标准要求,以号称兼容 SQL 标准。
|
||||
|
||||
但严格来说, SQL 对隔离级别的定义是有问题的,比如标准依赖于实现、几个隔离级别不连续、模糊不精确。很多数据库都号称实现了**可重复读**级别,但他们提供的保证却存在着很大差异。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。到最后,没有人知道可重复读的真正含义。
|
||||
但严格来说, SQL 对隔离级别的定义是有问题的,比如标准依赖于实现、几个隔离级别不连续、模糊不精确。很多数据库都号称实现了**可重复读**级别,但它们提供的保证却存在着很大差异。虽然一些文献中有对可重复读进行了精确定义,但大部分实现并不严格满足此定义。到最后,没有人知道可重复读的真正含义。
|
||||
|
||||
## 防止更新丢失
|
||||
|
||||
@ -427,9 +427,10 @@ UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old
|
||||
|
||||
### 写偏序的特点
|
||||
|
||||
上述异常称为**写偏序**(write skew),他显然不属**脏写**和**更新丢失**,因为这两个事务在更新不同的对象,这里的竞态条件稍微有点不明显,但他们的确存在竞态条件,因为如果顺序执行,不可能出现没人值班的后果。另一个常见的例子是黑白棋翻转。
|
||||
上述异常称为**写偏序**(write skew),它显然不属**脏写**和**更新丢失**,因为这两个事务在更新不同的对象,这里的竞态条件稍微有点不明显,但
|
||||
的确存在竞态条件,因为如果顺序执行,不可能出现没人值班的后果。另一个常见的例子是黑白棋翻转。
|
||||
|
||||
从单对象到多对象的角度来看,写偏序可以算作是更新丢失的一种泛化。写偏序本质也是 read-modify-write,虽然是涉及多个对象,但本质仍然是**一个事务的写入会导致另外一个事务的读取到信息失效**。补充一句,写偏序是由 MVCC 实现的快照隔离级别的特有的缺陷,它是由于读依赖同一个不变的快照引起的。
|
||||
从单对象到多对象的角度来看,写偏序可以算作是更新丢失的一种泛化。写偏序本质也是 read-modify-write,虽然是涉及多个对象,但本质仍然是**一个事务的写入会导致另外一个事务读取到的信息失效**。补充一句,写偏序是由 MVCC 实现的快照隔离级别的特有的缺陷,它是由于读依赖同一个不变的快照引起的。
|
||||
|
||||
解决更新丢失的很多手段,都难以直接用到解决写偏序上:
|
||||
|
||||
@ -485,11 +486,11 @@ COMMIT;
|
||||
|
||||
**多人棋类游戏**
|
||||
|
||||
之前提到的多人棋类游戏,对棋子对象加锁,虽然可以防止两个用户同时移动同一个棋子,却不能避免两个玩家将不同棋子移到一个位置。
|
||||
之前提到的多人棋类游戏,对棋子对象加锁,虽然可以防止两个玩家同时移动同一个棋子,却不能避免两个玩家将不同棋子移到一个位置。
|
||||
|
||||
**抢注用户名**
|
||||
|
||||
在每个用户具有唯一用户名的网站上,两个用户可能会并发的尝试创建具有相同名字的账户。如果使用检查是否存在改名字→没有则注册改名字流程,在快照隔离级别下,是没法避免两个用户注册到相同用户名的。当然,可以通过对用户名列加唯一性约束来保证该特性,这样,第二个事务在提交时会因为违反唯一性约束而终止。
|
||||
在每个用户具有唯一用户名的网站上,两个用户可能会并发的尝试创建具有相同名字的账户。如果使用检查是否存在该名字→没有则注册该名字流程,在快照隔离级别下,是没法避免两个用户注册到相同用户名的。当然,可以通过对用户名列加唯一性约束来保证该特性,这样,第二个事务在提交时会因为违反唯一性约束而终止。
|
||||
|
||||
**防止一钱多花**
|
||||
|
||||
@ -552,7 +553,7 @@ VoltDB/H-Store, Redis, and Datomic 实现了物理上的串行执行事务。由
|
||||
|
||||
### 将事务封装成存储过程
|
||||
|
||||
在数据库发展早期阶段,人们试图将数据库事务设计成为包含整个用户交互流程。如果整个交互流程都从属于一个事务,那么他们就可以被原子的提交,这么抽象看起来很干净。
|
||||
在数据库发展早期阶段,人们试图将数据库事务设计成为包含整个用户交互流程。如果整个交互流程都从属于一个事务,那么它们就可以被原子的提交,这么抽象看起来很干净。
|
||||
|
||||
但人的交互所引入延迟远大于计算机 CPU 时钟周期甚至 IO 延迟,因此 OLTP 型数据库多会避免在单个事务中包含人的交互,以求单个事务能够较快的执行结束。在 Web 上,这意味着,不能让单个事务跨多个请求。但如果只允许单次请求执行一个语句,一个完整流程通常会包含多个语句,从而包含多次 RPC\HTTP 请求,会在通信上耗费太多时间。
|
||||
|
||||
@ -608,7 +609,7 @@ TDOO:2PL 并非拿到所有的锁,才开始进行读写操作?而是按需
|
||||
|
||||
为了和书中保持一致,下面仍然称 2PL。
|
||||
|
||||
> 2PL 和 2PC 听起来很像,但他们不是一个东西,只是恰好都有两个阶段而已。
|
||||
> 2PL 和 2PC 听起来很像,但它们不是一个东西,只是恰好都有两个阶段而已。
|
||||
>
|
||||
|
||||
在防止脏写一节,提到了锁。但 2PL 中的锁会严格一些:
|
||||
@ -681,7 +682,7 @@ WHERE room_id = 123 AND
|
||||
前面小节详细聊了下数据库中隔离级别的图景:
|
||||
|
||||
1. 在光谱一侧,我们有很好的隔离级别——可串行化,但其实现要么性能差(两阶段锁),要么不可扩展(物理上串行执行)。
|
||||
2. 在光谱另一个侧,我们有一些相对较弱的隔离级别,他们性能较好,但会有各种竞态条件(更新丢失、写偏序、幻读等等)。
|
||||
2. 在光谱另一个侧,我们有一些相对较弱的隔离级别,它们性能较好,但会有各种竞态条件(更新丢失、写偏序、幻读等等)。
|
||||
|
||||
难道说强隔离级别和高性能两者不可得兼吗?
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user