我们继之后,再聊聊隔离级别(Isolation Level)。
隔离级别是为了解决并发所带来的问题的,我们期望并发的结果跟串行化(一个之后接一个)一样。实际上,串行化(Serializability)是最强的隔离级别,能解决世间所有并发问题带来的痛苦。那还有什么好说的?不难想象,串行化隔离有严重的性能问题,并且很多数据库都没有实现它,而是开发了一些弱隔离级别,每种隔离级别解决某些并发问题。所以,我们需要搞明白,到底存在哪些并发问题?某种隔离级别是如何解决问题的?如何选择隔离级别解决问题?我们的目的是利用工具和知识构建可靠,正确的应用程序,而不是盲目地使用工具。
隔离性如同双刃剑,一边是性能,另一边是安全。
Read Committed(读已提交)
最基本的隔离级别是read committed(读已提交),它提供了两个保证:
- 从数据库读时,只能读取已经提交的数据。(没有赃读,dirty reads)
- 写入数据库时,只会覆盖已经写入(提交)的数据。(没有赃写,dirty writes)
没有赃读
一个事务若能看到了另一个事务过程中(未提交或未中止)的数据,则为赃读。Read committed保障没有赃读,例如图1所示。
防止赃读解决的问题:
- 如果一个事务更新多个对象,赃读意味着其他事务可能会只看到部分更新。例如在中的图2,用户看到了新邮件,但是未读计数器却是0,让人很困惑。这种只看到部分更新的问题也可能导致错误的决定。
- 如果事务将来中止,那么之前的改变会被回滚掉。若允许赃读,那其他事务就有可能读到了某个将来被回滚的数据,感觉更不合理。。。。
没有脏写
如果两个事务同时更新数据库中的相同对象,我们通常认为后面的写入会覆盖掉前面的。但是,如果先前的写入是尚未提交事务的一部分,会发生什么?后面的写入会覆盖一个尚未提交的值,这叫做脏写。一般解决方法是延迟第二次写入,直到第一次写入事务提交或者中止为止。
防止脏写解决的问题:
如下图7-5所示,Alice和Bob同时在网站购买同一个商品(id:1234),操作需要写入数据库两次:网站的商品列表更新显示购买人,销售发票需要发送给买家。 但是由于脏写,最终网站显示Bob购买了商品(他脏写了Alice的购买),但是Alice收到了销售发票(她脏写了Bob的发票)。
另外,我们期望防止脏写也可以解决计数器加1的并发问题,但是仔细想想,并没有。第二个事务是在第一个事务提交后才进行覆盖的。后面会谈论这点。
实现Read Committed
Oracle 11g, PostgreSQL,SQLServer 2012,MemSQL和其他许多数据的默认设置都是Read committed.
使用行级锁(row-level lock)防止赃写。 一个事务需要修改数据前,必须获取该锁,并在事务期内一直持有。所以,另一个事务想要修改数据获取锁,必须等到这个事务结束(提交或者中止)。这种锁定是数据库自动完成的。
如何实现防止赃读? 一个办法是使用相同的锁,并要求读取后,立即释放锁。但是要求读锁对性能和响应时间是个挑战,因为一个长时间的写入事务可能会阻塞多个包含读取的事务,迫使它们等待很久,引起连锁反应。因此,大多数数据库会保留这个已经提交的旧值和当前持有写入锁的事务设置的新值。当事务进行时,任何其他读取对象的事务都会拿到旧值。事务提交之后,则后续的读取就会切换拿到新值。
快照隔离和可重复读(Snapshot Isolation and Repeatable read)
如果你认为Read Committed已经足够好去解决很多问题,那我们不妨看看下面的例子:
- 备份: 有时候在线备份整个数据库需要花费数小时的时间,并且整个过程数据库仍然要处理写入操作的。不难想象,备份可能会包含旧的数据和新的数据(备份过程中产生的数据),这样的备份是不一致的,没有用的。
- 分析查询:对于数据仓库而言,耗时的统计分析查询很多,如果在这期间,数据发生了变化或正在发生变化,查询将在不同时间看到数据库的不同部分,这样的分析结果应该不是业务需要的。
快照隔离(snapshot isolation)则是解决此类问题的方案,事务会看到事务开始时在数据库提交的所有数据。即使数据随后被另一事务更改提交,这个事务也只能看到自己开始那个时间点的旧数据。
实现快照隔离
快照隔离使用和Read committed一样的锁定。读取不需要锁定。所以,快照隔离遵循“读不阻塞写,写不阻塞读”。当处理一致性快照上的长时间查询时,可以同时处理写入操作,不会产生锁定争用。
为了实现快照隔离,数据库一般化了图1的中避免赃读的机制,数据库必须保留一个对象上几个不同提交的版本,因为多个事务可能在不同的时间点发出查询,看到不同时间点的数据状态。因为它并排维护着多个版本的对象,所以称此技术为多版本并发控制MVCC(Multi-version Concurrency Control)。
实现Read committed隔离,只需要保留两个版本即可(已经提交的版本和当前尚未提交的版本)。对于实现了MVCC的数据库,也使用其来实现Read committed隔离。读已提交隔离为每个查询使用单独的快照而快照隔离为每个事务使用相同的快照。我们通过下图7-7说明一下大体实现:每一个事务都被分配一个自增的事务id(txid),灰色表中的每一项都有created by表示创建者,最初其值为插入数据的事务id.数据删除使用deleted by标记(为了维护版本,只有在确定删除的数据未被当前系统的事务使用时再清理),UPDATE操作在内部被翻译为DELETE和INSERT(为了保留版本),如中间的那个灰色表格展示。
观察一致性快照的可见性规则
当一个事务从数据库读取时,事务ID用于决定它可以看见哪些对象。通过仔细定义可见性规则,数据库可以向应用呈现一致性快照。规则如下:
- 事务开始时,数据库列出正在进行的所有其他的事务列表,即使之后提交了,这些事务的写入也都会被忽略。
- 被中止的事务所执行的任何写入都将被忽略。
- 由较晚事务ID的所谓所做的任何写入都被忽略,而不管这些事务是否已经提交。
- 所有其他写入,对应用都是可见的。
图7-7中,事务12从账户2中读取数据,根据规则3,读取的应该是500. 对于事务12来讲,事务13所做的任何修改(删除和创建,其实是UPDATE)都被忽略。
如果以下两个条件成立,则对象可见。
- 读事务开始时,创建对象的事务已经提交。
- 对象未被标记删除,或者如果标记为删除,请求删除的事务在读事务开始时尚未提交。
由于每次只是创建一个新的版本,只会产生很小的额外开销。
索引和快照隔离
数据库中有多版本对象,那么索引该如何工作?一种方式是简单低使索引指向所有版本,需要索引查询来过滤掉不可见的版本。为了增加性能,可以将同一个对象的多版本放在同一页面上。 另一种办法是使用仅追加/写时拷贝,不会修改页面,只会增加新的B树,这些B树会形成一个一致性快照。
防止更新丢失
我们讨论下并发写入的第二个问题丢失更新(lost update)(第一个问题我们之前已经讨论过:脏写)。丢失更新(lost update)是我们在增量计数器中引入的问题,符合(读取-修改-写入)模式的并发写入都有类似问题。其中一个的更新可能会丢失,因为第二个事务或者线程没有在第一个事务的修改基础上写入。有几种解决办法。
- 增加计数器或者账户余额(读取当前值,修改,写回)
- 复杂值得本地修改:将元素添加到JSON中的一个列表,要求解析,添加,写回。
- 两个用户同时编辑WIKI页面,然后同时将整个页面发送至服务器保存。
原子写
有很多数据库已经实现了简单的原子写操作,比如以下数据库操作是原子的。
UPDATE counters SET value = value + 1 WHERE key = 'foo';
原子操作通常通过在读取对象时,获取其上的排它锁来实现。保证更新完成之前没有其他事务可以读取它。这种技术也叫作游标稳定性(Cursor stability)。另一个选择是简单地强制所有原子操作在单一线程上执行。
值得一提的是,ORM框架会执行不安全的“读取-修改-写入”操作,而不是数据库的原子操作。 所以小心。
显示锁定防止丢失更新
让应用程序显式锁定(for update)行以防止丢失更新.
BEGIN TRANSACTION;SELECT * FROM figuresWHERE name = 'robot' AND game_id = 222FOR UPDATE; --显式锁定UPDATE figures SET position = 'c4' WHERE id = 1234;COMMIT;
自动检测丢失的更新
数据库事务管理器可以结合快照隔离高效地检测是否存在丢失更新,如果是,则中止事务并强制他们重试。PostgreSQL的可重复读,Oracle的可串行化,SQLServer的快照隔离级别(其实他们都是快照隔离级别的不同名字,没有严格的标准命名多混乱呀),都会自动检测到丢失更新,并中止事务。MySQL/InnoDB貌似不支持。
不得不说,这个办法好,干净。
比较并设置(CAS)
有些数据库提供一种原子操作:CAS(比较设置),目的是为了防止丢失更新:只有当前值从上一次读取以来没有变化,才允许更新。否则,更新失败,并重试。
比如,为防止两个用户同时更新一个WIKI页面,可以使用这种方法。
写入偏差与幻读
前面,我们讨论了脏写和丢失更新,在并发写入相同对象的时候,会出现两种问题。我们可以用过数据库自动解决,也可以通过锁和原子写操作手动防止。
但是,由于并发写入带来的问题还没完,我们在本节中会看到一些更有趣的问题。首先,我们想象一下医院的值班系统,业务要求任何时刻必须有至少一名医生值班(on call),医生自己也可以通过系统申请休假(前提是满足至少一名医生值班的条件即可)。如下图7-8所示,目前有两位医生Alice和Bob都是on call状态,他们同时向系统请假。 开始他们查询当前值班的医生数量,结果是2(在快照隔离级别下),基于这个查询结果,他们都做出了请假操作并且事务提交成功。但是现在我们发现已经没有医生值班了,违反了业务要求!
写偏差的特性
这种问题称为写偏差,它既不是脏写,也不是丢失更新,因为写入的是不同的对象。虽然这里的写入冲突不明显,但是显然是一个竞态条件:如果两个事务一个接一个地运行,第二个医生就不能休假了。异常行为只有在并发进行时才有可能出现。
我们虽然有很多方法防止丢失更新。随着写入偏差,选择更受限:
- 涉及多个对象,单对象原子操作不工作。
- 快照隔离无法检测,而需要真正的可串行化隔离。
- 使用触发器等实现这种约束。
- 如果无法使用可串行化的隔离级别,则可以显示锁定事务依赖的行。在例子中,可以写下如下代码。FOR UPDATE告诉数据库锁定返回的所有行用于更新。
BEGIN TRANSACTION;SELECT * FROM doctors WHERE on_call = TRUE AND shift_id = 1234 FOR UPDATE; UPDATE doctorsSET on_call = FALSEWHERE name = 'Alice'AND shift_id = 1234;COMMIT;
写偏差的更多例子
会议室预订系统
我们不允许同一会议室在同一时间内重复预订。当有人预订时,首先检查是否存在互相冲突的预订(即预定时间范围重叠的同一房间),如果没找到,则创建预订。
BEGIN TRANSACTION;-- 检查所有现存的与12:00~13:00重叠的预定SELECT COUNT(*) FROM bookingsWHERE room_id = 123 ANDend_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';-- 如果之前的查询返回0INSERT INTO bookings(room_id, start_time, end_time, user_id)VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);COMMIT;
快照隔离并不能防止另一个用户同时插入冲突的会议,看来是得需可串行化级别了。
抢注用户名
同样的道理,两个用户同时注册同样的用户名(若没有,则创建)。在快照隔离下和前面的例子一样不安全。但是可以通过唯一性约束来解决。
导致写入偏差的幻读
这些列子都遵循类似的模式
- 一个SELECT查询出符合条件的行,并检查是否符合一些要求。(例如,至少有两名医生值班;不存在对会议室同一时间段的预订;用户名还没有被抢注。。。)
- 按照第一个查询结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
- 如果应用决定继续操作,就执行某种写入(增删改),并提交事务。这个写入的结果改变了步骤2中的先决条件。换句话说,提交写入后,重复执行一次步骤1的SELECT查询,将会得到不同的结果。因为写入改变了符合搜索条件的行集(少了一个医生值班,会议室被预订了,用户名已经被抢注。。。)
这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行SELECT查询,最后根据查询结果决定是放弃还是提交(因为这些操作都在同一个事务中)。
在医生值班例子中,步骤3修改的行,是步骤1返回的结果的一部分,所以我们可以通过锁定步骤1中的行(SELECT FOR UPDATE )来使事务安全并避免写入偏差。但其他例子不同,他们检查是否不存在某些满足满足条件的行,写入会添加一个匹配相同条件的行。如果步骤1中的查询没有返回任何行,则SELECT FOR UPDATE无行可锁。
这种现象:一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读。快照隔离避免了只读查询中的幻读,但在我们这里讨论的读写事务中,幻读会导致棘手的写倾斜情况。
物化冲突(Materializing conflicts)
对于有些幻读问题,我们没有对象加锁,但我们可以人为地引入锁对象。
比如,在会议室预订的场景中,我们可以创建一个关于时间槽和房间的表。此表中每一行对应于特定时间段(例如15分钟)的特定房间,可以提前插入房间和时间的所有可能组合(例如接下来的半年时间)。
现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行,之后,检查重叠的预订是否存在,若不存在,则插入新的预订。这个表的目的只是为锁定提供实体对象,从而防止对同一时间段的同一会议室信息的并发访问。
这种方法被称为物化冲突。不幸的是,弄清楚如何物化冲突可能很难,而让并发控制机制暴露到应用数据模型中是很丑陋的做法。所以,这种方法不推荐。大多数情况下,可串行化的隔离级别是可取的。
可串行化(Serializability)
最强的隔离级别,事务可以并行执行,但是结果就跟没有任何并发性,连续一个接一个执行一样。所以,数据库可以防止所有的并发问题。我们分析三种实现技术。
真正串行执行
即在单线程中按照顺序一个接一个地执行事务。有两个原因支持
- RAM足够便宜: 活跃的数据集可以全部放入内存,避免对磁盘的访问。
- OLTP操作都足够短。
串行执行事务的方法在VoltDB/H-Store,Redis和Datomic中实现。
在存储过程中封装事务
具有单线程串行事务处理的系统不允许交互式的多语句事务(因为会严重影响系统的吞吐量,因为数据库大部分时间都在等待当前事务的下一条语句)。取而代之,应用程序必须提前将整个事务代码作为存储过程提交给数据库。这些方法之间的差异如图7-9 所示。如果事务所需的所有数据都在内存中,则存储过程可以非常快地执行,而不用等待任何网络或磁盘I/O。如下图7-8所示:
分区
如果能找到将数据集分区的办法,使每个事务的读写只会限定在一个分区之上,那么每个分区就可以拥有独立运行的事务处理线程,运行在独立的CPU核心。但对于跨多个分区的事务(比如二级索引),则需要多个分区之间的协调锁定,会带来很大的开销。
串行执行小结
- 事务小而快
- 数据集在内存中。如果不在内存,可以先中止事务,然后异步地将数据加载到内存,同时处理其他事务,等数据加载完毕后,重启执行事务。
- 写入吞吐量必须低到能在单核CPU核上处理,否则,事务要能划分到分区,且不要跨分区协调。
- 跨分区事务是可能的,但是使用程度有很大限制。
2PL(Two-phase Locking)
大约30年来,在数据库中只有一种广泛使用的串行化算法:两阶段锁定(2PL,two-phase locking)。其对锁的要求更强。写会阻塞读,读也会阻塞写(而快照隔离是写和读都不会阻塞对方)。 其实现使用了两种对象锁模式,共享模式(shared mode)和排它模式(exclusive mode)。工作如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁(允许多读)。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。(写阻塞任何操作)
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排它锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有锁
由于使用了很多锁,2PL比其他隔离级别很容易发生死锁问题。数据库会自动检测到死锁,并中止其中一个事务,另一个才能继续执行。
2PL的性能很差。一方面是由于锁的获取和释放的开销,但更重要的是,锁等待使并发性极大地降低。
谓词锁
谓词锁不属于特定对象上的锁,它属于所有符合特定查询条件的对象。如:
SELECT * FROM bookingsWHERE room_id = 123 ANDend_time > '2018-01-01 12:00' ANDstart_time < '2018-01-01 13:00';
谓词锁限制访问,如下所示:
- 如果事务A想要读取匹配某些条件的对象,就像在这个 SELECT 查询中那样,它必须获取查询条件上的共享谓词锁(shared-mode predicate lock)。如果另一个事务B持有任何满足这一查询条件对象的排它锁,那么A必须等到B释放它的锁之后才允许进行查询。(当B得到或者升级为排它锁并插入一条预订的时候,A是不能查询当前会议室同一时间段的预订情况的)。
- 如果事务A想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务B持有匹配的谓词锁,那么A必须等到B已经提交或中止后才能继续。(如果A想要更新会议室预订,但是此时B已经获取了谓词锁,A则无法升级谓词锁为排它锁,而是必须等待B的提交或者中止。如果A,B同时更新,就会发生死锁)
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。因此,大多数使用2PL的数据库实际上实现了索引范围锁(也称为间隙锁(next-key locking)),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午1点之间预订123号房间的谓词锁,则锁定123号房间的所有时间段,或者锁定12:00~13:00时间段的所有房间(不只是123号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入,更新或删除同一个房间和/或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
串行化快照隔离(SSI:serializable snapshot isolation)
2PL和真正的串行化是一种对并发的悲观控制技术,他们假设任何并发都可能带来问题。而SSI采用乐观的并发控制技术:在这种情况下,乐观意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(即隔离是否被违反);如果是的话,事务将被中止,并且必须重试。只有可序列化的事务才被允许提交。其实现方式是通过检测过时的前提条件,即当一个事务中的写入提交时,数据库会检测是否存在一个之前的读取,并认为他们有因果关系。如果这个读取的结果被另一个事务修改过,则中止此事务。以下是两种检测情形:
- 检测对旧MVCC对象版本的读取(读之前存在未提交的写入)
- 检测影响先前读取的写入(读之后发生写入)
其也有明显的优缺点,在并发很高的情况下,被中止的事务会很多。而并发争用很少的情况下则表现很好。中止率显著影响SSI的整体表现。例如,长时间读取和写入数据的事务很可能会发生冲突并中止,因此SSI要求同时读写的事务尽量短。