《数据密集型应用系统设计》阅读笔记(2)

继续做笔记。

7 事务

7.1 深入理解事务

7.1.1 ACID的含义

7.1.1.1 原子性

事务要么都完成,要么一个也完不成。

7.1.1.2 一致性

对数据有特定的预期状态,任何更改必须满足这些约束。严格来说这并不属于数据库的属性,而是属于应用层的属性。

7.1.1.3 隔离性

并发执行的多个事务相互隔离。

7.1.1.4 持久性

一旦事务成功,即使存在硬件故障或者数据库崩溃,事务写入的数据也不会消失。

7.2 弱隔离级别

介绍几个常用到的弱级别(非串行化)隔离。

7.2.1 读-提交隔离 read committed

7.2.1.1 防止脏读

脏读:某个事务完成部分数据写入,但尚未提交。另一个事物若能看到尚未提交的数据,就是脏读。防止脏读意味着,事务的任何写入只有在成功提交之后,才能被其他人观察到。

以下场景需要防止脏读:

  1. 事务更新多个对象。
  2. 事务发生终止,所有写入操作需要回滚。

7.2.1.2 防止脏写

两个事务更新相同的对象,后写的操作会覆盖先写的操作。如果先前的写入是未提交事务的一部分,且被覆盖,就是脏写。read committed隔离级别下提交的事务可以防止脏写,通常方式是推迟第二个写请求,直到前面的事务完成提交。

7.2.1.3 实现读提交read committed

使用行级锁实现。当事务想修改某对象时,它必须首先获得该对象的锁,然后一直持有该锁直到事务提交。如何防止脏读?一种做法是获取相同的锁,但会严重影响性能。大多数数据库对于待更新的对象,会维护旧值和事务要设置的新值2个版本。事务提交前,其他读操作都会读取旧值,提交后才会切换到读取新值。

7.2.2 快照级别隔离和可重复读 snapshot isolation

不可重复读取(nonrepeatable read)或读倾斜(read skew)。在连续读取中,由于事务的发生,造成了数据一致性被破坏。例如在转账过程中分两次读取2个账户的值,原来各是500、500,读取到了500、400,总额不是1000而是900。

有的时候可以短暂容忍这种不一致,有的时候则不能:

  1. 备份场景,需要做snapshot
  2. 分析查询与完整性检查场景

快照级别隔离是解决上述问题的常用手段。PostgreSQL,MySQL的InnoDB引擎,Oracle,SQL Server都支持。

7.2.2.1 实现快照级别隔离

考虑到多个正在进行的事务可能会在不同的时间点查看数据库状态,所以数据库保留了对象多个不同的提交版本,称为多版本并发控制(Multi-Version Concurrency Control, MVCC)。

一个更新操作会被转换为一个删除操作和一个创建操作,并标记created_by和deleted_by (with txn id)。

7.2.2.2 一致性快照的可见性规则

  1. 每笔事务开始时,数据库列出的其他进行中的事务,这些事务完成的部分写入不可见。
  2. 所有中止事务所做的修改全部不可见。
  3. 晚于当前事务做的修改不可见。
  4. 除此之外,任何写入都对查询可见。

仅当以下两个条件都成立,该数据对象对事务可见。

  1. 事务开始的时刻,创建该对象的事务已经完成了提交。
  2. 对象没有被标记为删除;或者即使标记了,但删除事务在当前事务开始时还没有提交。

7.2.2.3 索引与快照级别隔离

支持MVCC的数据库如何支持索引?

  1. 索引直接指向对象的所有版本,想办法过滤对当前事务不可见的版本。当旧对象版本被删除时,对应的索引条目也要被删除。
  2. 当需要更新对象时,不修改现有页面,而总是创建一个新的修改副本,拷贝必要内容,然后让父节点,或者递归向上直到树的root节点都指向新创建的节点,创建一个该时刻的一致性快照。

7.2.3 防止更新丢失

读-提交和快照级别隔离都是为了解决只读事务遇到并发写时可以看到什么的问题,但还有另外一类情况,即两个写事务并发。(脏写只是其中一个特例)

具体场景:两个写事务都是read-modify-write过程,由于隔离性,第二个写操作并不包括第一个事务修改后的值,导致第一个事务写入的修改值可能丢失。

几个具体场景:

  1. 递增计数器,更新账户余额
  2. 对复杂对象的一部分进行修改
  3. 两个用户同时编辑wiki页面,且每个用户都尝试将整个页面发送到服务器

7.2.3.1 原子写操作

利用DB的原子更新操作,如

UPDATE counters SET value = value + 1 WHERE key = 'foo';

7.2.3.2 显式加锁

对象粒度加锁。

7.2.3.3 自动检测更新丢失

先让事务并行执行,事务管理器检测更新丢失风险,然后终止当前事务并强制回退。DB可以借助快照级别隔离来高效的执行检查。但是MySQL的可重复读却不支持检测更新丢失。

7.2.3.4 原子比较和设置

通过加乐观锁来实现有效更新。

UPDATE wiki_pages SET content = 'new content' WHERE id = 1234 AND content = 'old content';

7.2.3.5 冲突解决与复制

以上讨论(加锁与原子修改)都有一个前提:只有一个最新的副本。但是对于多副本的无主节点集群和多主节点集群,并无最新副本的概念。同时集群数据库支持多个并发写,所以对于写冲突,保留多个冲突版本,让应用层去解决冲突。或者采用某些策略,如LWW,来解决冲突。

如果采用LWW,则容易丢失冲突。

7.2.4 写倾斜与幻读

两医生值班,同时请假的例子。if 备值班医生有空 then 请假成功并更新本人值班状态。可能两人同时请假成功。

7.2.4.1 定义写倾斜

将写倾斜定义为一种广义的更新丢失问题。即,如果两个事务读取相同的一组对象,然后更新其中一部分:不同的事务可能更新不同的对象,则可能发生写倾斜;而如果更新的是同一个对象,就是脏写或更新丢失。

7.2.4.2 更多写倾斜的例子

  1. 会议室预订系统
  2. 多人游戏(两玩家将两个棋子移动到相同位置)
  3. 声明一个用户名

7.2.4.3 为何产生写倾斜

  1. 首先输入一些匹配条件,即采用SELECT查询所有满足条件的行。
  2. 根据查询结果,应用层决定下一步操作。
  3. 如果应用决定继续执行,则进行INSERT/UPDATE/DELETE等操作并提交事务。而这些写操作会改变步骤2的决定。

这种在一个事务中的写入改变了另一个事务查询结果的现象,我们称为幻读。

7.2.4.4 实体化冲突

抽象出对象,对对象进行加锁。

7.3 串行化

可串行化隔离通常被认为是最强的隔离级别。它保证事务可能会并行执行,但最终的结果与串行执行结果相同。

目前大多数可串行化数据库都使用了以下3种技术之一。

  1. 严格按照串行顺序执行。
  2. 两阶段加锁。
  3. 乐观并发控制技术。

7.3.1 实际串行执行

Redis采用串行方式执行事务(单线程)。

2点考虑:

  1. OLTP事务通常很快。
  2. 内存越来越便宜。当事务所需数据都在内存中时,执行速度比等待磁盘IO要快得多。

7.3.1.1 采用存储过程封装事务

由于交互式事务耗费时间较长,严重影响性能,所以我们把整个事务代码打包发送给数据库,称为一个存储过程。关系型数据库支持存储过程很久了,并且都有自己的存储过程语言。

不过这些问题也可以克服。最新的存储过程已经放弃了老旧的存储过程语言。Redis使用lua脚本。

7.3.1.2 分区

将数据进行分区可以线性增加执行吞吐量。但如果引入了跨分区事务,将比普通事务慢很多。

7.3.2 两阶段加锁 two-phase locking

7.3.2.1 实现两阶段加锁

目前,2PL已经用于MySQL的InnoDB的可串行化隔离。

数据库的每一个对象都有一个读写锁,基本用法如下:

2-phase locking的由来:第一阶段即事务执行前,要获取锁;第二阶段即事务结束时,要释放锁。

使用了这么多的锁,很容易发生死锁。数据库系统会自动检测事务间的死锁,并强行终止其中一个以打破僵局。被终止的事务的重试由应用层完成。

7.3.2.2 2-phase locking的性能

主要缺点:事务吞吐量和查询响应时间相比其他弱隔离级别下降非常多。

2PL下,数据库的访问延迟具有非常大的不确定性。死锁可能更加频繁,导致重试,性能下降。

7.3.2.3 谓词锁、索引区间锁

谓词锁:实体化冲突的一种方式。锁的检测将会十分耗时。

索引区间锁:将锁的粒度加大,不那么精确。

7.3.3 可串行化的快照隔离

Serializable Snapshot Isolation, SSI

本质上是乐观并发控制技术。

2-phase locking是一种典型的悲观并发控制技术:如果某些操作可能出错,那么直接放弃,采用等待方式直到绝对安全。

串行执行是极度悲观的选择。

可串行化的快照隔离是一种乐观并发控制:如果发生潜在冲突,事务会继续执行;只有当提交的时候,数据库才会检查是否发生了冲突,若发生了,终止并重试。

SSI基于快照隔离。

7.3.3.1 基于过期的条件做决定

写倾斜和幻读的case中,在快照隔离的情况下,数据可能在查询期间就被其他事务修改,导致原事务将要采取的行动变化。

为了提供可串行化的隔离,数据库必须检测事务是否会修改其他事务的查询结果,并在此情况下终止写事务。

数据库如何知道查询结果是否发生了改变?分两种情况:

  1. 读取时,读取到了一个即将过期的MVCC对象(读取前已经有未提交的写入)
  2. 写入时,发现有已经完成的读取(此次写入将会改变该已经完成的读取结果)

7.3.3.2 检测是否读取了过期的MVCC对象

这个很容易。读取时会看到其他事务的写入记录。

7.3.3.3 检测写入是否影响了之前的读

读的时候加锁。写的时候就可以检测到了。

7.3.3.4 可串行化快照隔离的性能

取决于工程实现。例如跟踪读、写的粒度。

优点:读写不会互相阻塞,使得延迟更加稳定。只读不需要任何锁。可以突破单个cpu核的限制。相比于2PL和串行执行,SSI更能容忍缓慢的事务。


本书的后半部分中,主要有3章,分别是一致性与共识、批处理系统、流处理系统。“一致性与共识”中主要讲了2-phase commit和一些共识的基础知识,“批处理系统”中主要讲述了map-reduce的思想,“流处理系统”中主要讲述了包含了Kafka在内的消息中间件的设计思想,干货较少,故不列出。