Transactions
现实中的数据系统,会有很多故障发生:
- 数据系统或者硬件随时有可能故障,包括写操作正在执行时.
- 应用程序随时可能宕机.
- 网络故障使得应用无法连接数据库,或者数据库节点之间无法连接.
- 客户端获取到的数据只被部分更新.
- 客户端之间的竞态条件导致难以预料的bug.
事务用来简化容错机制实现.事务机制使得一些读和写原子写入,要么都成功,要么都失败.如果失败了,应用层可以安全地进行重试.有了事务,应用的错误处理会变得非常简单,不用担心部分写入成功部分写入失败了.事务并不是一个自然规律,事务的产生是有原因的,就是为了应用访问数据库时,能简化编程模型.通过使用事务,应用层无需关心潜在的错误可能和并发问题.当然也并非所有的应用都需要事务,有时候可以通过弱化事务保证甚至完全抛弃事务,来获得更高的性能和可用性.那么到底我们的程序要不要使用事务呢?这里需要深入了解事务究竟能带来什么样的安全保证以及伴随这些保证牺牲了什么.尽管事务看起来很直观,实际上有很多微秒但是重要的细节.
难以掌握的事务概念
几乎所有的关系型数据库和一些非关系型数据都实现了事务.2000年末nosql开始流行,通过提供新的数据模型和默认提供备份和分区来改善关系型数据库的现状.然而事务却被忽略了,很多新兴的数据库完全抛弃了事务,或者重新定义事务,使得事务包含了较弱的保证.
ACID的含义
事务提供的安全保证通常被描述为ACID,代表了Atomicity, Consistency, Isolation, and Durability,是为数据库容错机制提出的精确术语.但是实际上,一个数据库的ACID实现不同于另一个数据库系统的实现,单就隔离性而言都有很多模棱两可的地方.因此ACID的实现有时候可能会和你的期望是不一致的.不满足ACID条件的系统有时被称为BASE,也就是Basically Available, Soft state, and Eventual consistency.
Atomicity
事实上,原子代表那些无法被切割成更小部分的东西.在不同的计算机分支里,原子代表着相似但又有微秒差别的东西.例如,在多线程语言里,如果一个线程执行一个原子操作,这意味着另一个线程不会看到执行到一半的结果,系统只可能处在执行前或者执行后的状态,没有办法处在中间状态.相比之下,在ACID语境下,原子性不是关于并发的,它不是用来表示多个进程同事访问同一个数据,这部分是通过I,也就是isolation.
ACID的原子性描述了如果一个客户端执行多个写操作,而其中某个写操作发生了错误.如果这些写操作被组合成一个原子的事务,那么该事务由于某个写操作的错误从而无法提交完成,因此这个事务就需要被取消.那么事务里包含的任何写操作都需要被丢弃.如果没有原子性,当错误发生时很难定位哪些操作已经执行成功,而哪些操作还未执行.应用程序可以尝试重试,但这导致一些操作被执行两次,从而出现重复或者不正确的数据.原子性简化了这个问题,如果一个事务被取消了,那么应用程序可以确信没有发生任何的更改,可以安全地进行重试操作.
Consistency
一致性这个词汇被严重的滥用了:
- 在异步化的复制系统里,副本一致性和最终一致性被提出来.
- 一致性hash是一种分区的方法,一些系统用它来进行重新调整.
- 在CAP理论里,一致性被用来代表线性一致性.
- 在ACID环境下,一致性是指数据库处于”好状态”的特定于应用程序的概念.
这一个词就有四个不同的含义,在ACID环境下,如果当前数据库在不变量约束下是正确的,然后开始一个事务,然后事务中所有的写操作都是保证了这个约束,那么我们可以确信不变量约束始终是安全的.怎么理解呢?具体可以参考这里如何理解数据库事务中的一致性的概念.核心在于,一致性是一种应用层的特性,即应用程序从一个正确的状态迁移到另一个正确的状态,这时我们说应用程序保持了一致性,所谓正确的状态是应用层也就是开发者定义的,而不是数据库的特性.而事务就是通过AID手段来保持一致性的,状态的迁移不是瞬时的,是有中间过程的,而事务能够保证状态的安全迁移.
Isolation
大部分的数据库会被客户端并发访问同一行记录,这时候就会遇到并发问题(竞态条件).因此隔离性的含义就是说,并发执行中的事物之间都是互相隔离的.经典数据库教材里将隔离性定义成可串行化,这意味着每个事物都能保证整个数据库只有它自己在运行.数据库来保证,当事物提交的时候,得到的最终结果和事务一个接着一个的运行结果是一样的,尽管事实上他们是并发运行的.但是实际上,数据库很少采用可可串行化这个隔离级别,因为太伤性能了.
Durability
数据库系统本身的目的就是为了提供一个安全存储数据的地方,防止数据的丢失.事务的持久化是指一旦事务提交成功,数据一定不会被丢失.在单机系统中,持久化通常是指数据被写入磁盘中,通常这个持久化过程会包含预写式日志或者相似的结构,从而当磁盘数据损坏后可以恢复.而在分布式系统中,持久化意味着数据被拷贝到了一些节点上,为了保证持久化,数据库必须等这些写操作和备份都完成了,才能让一个事务成功提交.当然,百分百的数据安全是不存在的,如果不巧你所有备份的磁盘都挂掉了,你的数据就一定丢失了.
单目标与多目标操作
总的来说,在ACID中,原子性和隔离性描述的都是在同一个事务里,如果一个客户端执行多个写请求数据库应该怎么做.
- 原子性:如果写序列执行过程中发生了错误,事务应该回滚,而写操作带来的影响应该被丢弃.也就是说事务保证你不用担心部分失败,要么都成功要么都失败.
- 隔离性:并发执行的事务不应该影响彼此.例如如果一个事务执行了多个写操作,另一个事务要么看到全部的写入,要么什么也看不到.
多目标事务需要确定哪些读和写操作是隶属于相同的事务的,在关系型数据库里主要是基于客户端的TCP连接,任何连接中的开始事务和提交事务声明之间的操作都被认为是同一个事务.而对于很多非关系型数据库,则没有提供这种接口出来.
单目标写入
当单个目标写入时,原子性和隔离性就会起作用,这是数据库默认的操作.原子性可以通过日志恢复来实现.隔离性可以通过目标上加锁,来保证只有一个线程能写入.
一些数据库还提供一些更复杂的单目标原子操作,例如自增长,类似的还有CAS.
当多个客户端试着同时写入同一个目标时,这些操作能防止更新的丢失.但是通常意义上来说他们并不是事务,有时被称为轻量级事务.事务是一种将多个操作行为或者多目标操作集中到一个执行单元的机制.
多目标事务
许多分布式数据系统都放弃了多目标事务的实现,因为很难跨分区实现,而且在高可用和高性能的场景需求下,多目标事务会成为瓶颈.但是我们的系统不可能只依赖kv数据库或者单目标操作.
- 在关系型数据库中,某个table依赖的外键要更新,那么就需要多目标事务同事更新他们.
- 在文档型数据库中,当非规范化的信息更新时,会涉及到多个文档内容的更新,也需要事务.
- 对于存在辅助索引的数据库,索引的更新操作也需要事务保护.
处理错误和取消操作
事务一个关键的特性就是,当错误发生时可以被取消然后安全的重试.而对于无主副本架构里,设计原则就是数据库尽最大能力去做,但是不会回滚已经做过的事情.因此这就需要客户端去处理异常信息.
重试操作是一种简单并且有效的错误处理机制,但是并不完美:
- 如果事务实际上成功了,但是网络失败了,这是客户端认为还是失败了,这时候如果重试会导致事务执行两次,除非你有额外的去重机制.
- 如果错误是由于系统超载了,那么重试事务其实会加重问题.因此我们可以尝试限制重试次数,使用指数退避,或者把超载错误和其他错误分开对待.
- 面对瞬时异常(例如死锁了,违反隔离性了,网络问题啊)重试是有意义的,但是对于永久性错误(违反了约束条件)重试是毫无意义的.
- 如果事务执行除了数据库外还有副作用,并且即使取消事务了副作用仍然会发生,那么重试就会有问题.比如事务开启了就会发送邮件出去.(这有点儿分布式事务的意味,为了解决这个问题,可以引入两阶段提交)
- 重试的时候客户端进程挂掉了,那么很显然数据也就没了.
弱隔离级别
如果两个事务触及的相同数据,它们可以安全的并行运行。竞态条件只在一个事务读取数据同时另一个事务并发更改该数据时起作用,或者两个事务同时试图更改同样的数据。长久以来,数据库通过事务的隔离性将并发问题隐藏掉,使得开发者不需要考虑并发问题。实际上,可串行化的隔离级别很少被真正使用,因为太损耗性能了,因此很多数据库选择提供较弱一些的隔离机制,能够解决部分并发问题.这些隔离机制比较难理解,也有可能会导致比较微妙的bug.由于弱隔离性导致的并发bug不仅仅是理论上的,也是在真实环境中存在的.
不同的隔离级别下,有些竞态条件会被触发,有些则不会.
读已提交
最基本的隔离级别就是读已提交,有两个基本保证
- 当从数据库读取数据时,你只能看到已经写入的数据(没有脏读)
- 当写入数据库时,你只能复写已经被提交的写入(没有脏写)
没有脏读
当事务执行过程中,写入了一些数据但是还没有提交或者取消,如果其他事务可以读到这些写入数据,那么这就叫做脏读(dirty reads).对于读已提交级别的事务,应该能够避免脏读的发生,当事务没有提交前,写入数据对于其他的事务将是不可见的,当事务完成提交后,对于其他事务则是立即可见.脏读的意义:
- 当一个事务在更新若干个目标时,如果有脏读,意味着其他事务只能看到部分更新.看到部分更新中的状态,这会导致用户在其他事务里做出错误的选择.
- 如果事务取消了,那么事务中的写操作都会被回滚.如果数据库允许脏读,那么其他事务看到的数据很有可能晚些时候就被回滚了,那么其他事务的结果就会变得匪夷所思.
没有脏写
如果两个事务同时进行写操作呢?我们无法知晓写的顺序是什么,但很明显后续的写操作将会覆盖前者.不过,如果前一个写入是一个尚未提交的事务的一部分,那么后一个写入就复写了未提交的数据,因此这就造成了脏写(dirty writes).读已提交隔离级别必须能够避免脏写,通常会在前一个写入操作的事务提交或者取消后,才继续进行后一个写入操作.这样避免了一些问题:
- 当事务进行多目标更新时,脏写会导致错误的输出.例如两个人同时同买一辆车,需要更新数据库将车锁定给一个人,然后还要在写入一份该车的账单给他.如果发生了脏写,那么就会导致车锁定了一个人,但是账单被另一个人的记录复写了.
- 但是读已提交无法避免计数器并发自增长的问题.因为这不是一个脏读问题,但是结果仍然是不对的.
实现读已提交
数据库通过行锁来实现防止脏写:当一个失误试图修改一个特定目标时(可以是一行或者文档),必须首先获取到目标上的锁,然后直到事务提交或者取消.任何目标的锁都只能被一个事务获取.
脏读怎么解决呢?一种可选的方案就是采用相同的锁,任何一个事务想要读取目标必须获取锁,读完后释放锁.但是这种方法实际工作性能并不好,一个长时间运行的写入事务会阻塞许多只读的事务操作.应用程序中一个部分慢,结果导致不相干的功能因为等待锁也变慢了,所以这是不可取的.大部分数据库采用这样一种策略:任何目标被写入时,数据负责记录旧值,当其他事务读取时直接返回旧值,当新值事务被提交了,其他事务才会读到新值.
快照隔离和可重复读
不可重复读
由于前后两次读取操作之间,间隔了完整的事务,导致读到的数据不一致.有些时候这个问题是无法容忍的:
- 备份:备份需要获取整个数据库的一个拷贝,这会花费几个小时来进行,与此同时写操作也在执行中.因此备份中部分是老数据,部分是新数据.当你从一个备份中恢复数据时,这种不一致性就成为永久的了.
- 分析查询或者完整性检查:每次查询的结果在不同时间点结果都是不一致的.
快照隔离性能够解决不可重复读问题.大体的思想是这样的,每个事务读取的都是数据库的一个一致性的快照,也就是说事务看到的全部数据都是自事务开始就已经被提交的数据.即使数据随后被其他事务更改了,其他事务也只能看到特定时间点的旧数据.
快照隔离性实现
为了解决脏写问题,快照隔离性还是采用了写锁的方式.但是读的时候就不需要锁了.一个基本理念就是读者永远不会阻塞写者,反之亦然.这就使得数据库在处理写操作时,还能够提供长时间运行的读取操作,而两者之间不会互相干扰.
为了解决脏读和不可重复读,快照隔离性采用了之前机制的一个泛化版本.那就是数据库会针对通一个目标,保存不同提交版本的值,因为不同的事务会在不同的时刻去执行.这种机制被称为多版本并发控制(multiversion concurrency control (MVCC))
如果数据库只需要保持读已提交隔离性,那么就只需要保留两个版本,一个已提交的版本,一个重写但尚未提交的版本.不过数据库通常采用MVCC来实现读已提交隔离性.已搬情况下数据表会包含create_by域和delete_by域,分别存储插入时刻的事务id和删除时刻的事务id.
当事务读取时,事务id用来决定读取哪个版本:
- 在事务开始时,数据库列出全部正在执行中的事务,这些事务里所有的写操作都会被忽略,即使这些事务随后就被提交了.
- 任何被取消的事务写操作都被忽略.
- 任何有较大事务编号的写操作都会被忽视,不管他们有没有被提交.
- 其他的写操作都是可见的.
这些规则也适用于插入和删除操作.除此之外以下的条件也是对的:
- 当一个读事务开始的时候,插入事务已经提交了.这时数据是可见的.
- 当数据被标记为删除,或者已经标记为删除了.只要删除事务还没有提交,而读取事务开始了,那么这个数据仍然是可见.
MVCC从不本地更改数据,而是通过新建数据版本,从而能够提供一致的快照.
索引和快照一致性
那么索引如何在多版本数据库里工作呢?一个选择就是索引指向数据的所有版本,然后索引查询的时候将针对当前版本不可见的数据都过滤掉.后续GC会将那些对任何事务都不可见的版本清理掉.
实际上,具体实现细节决定了多版本并发控制的性能.B数索引比较常见的策略是append-only/copy-on-write,当更新时并不复写页面,而是新建一个被更改页面的拷贝,从父节点到根节点都被拷贝出来然后指向新版本的子节点,而不受影响的节点则不必拷贝.于是,每个写事务都会对应一个B数的根,每个根对应了某个时刻下的一致性快照,于是读取操作就不再需要根据事务id来过滤了.不过这仍然需要后台进程来合并和垃圾回收.
丢失更新
读已提交和快照隔离性都解决了在并发写事务中如何保证读事务的正确性.但是忽略了并发写事务的问题.我们只是简单的讨论了脏写的问题,这只是写写冲突的一个特殊类型.还有其他的并发写问题,最常见的就是丢失更新(lost update)问题,当应用程序读取相同的数据,然后更改然后再写回去(read-modify-write),并发执行时就会导致一个更新操作丢失了,因为第二个写操作没有看到第一个更新操作.
原子写操作:许多数据库提供了原子更新操作UPDATE counters SET value = value + 1 WHERE key = 'foo';,对于MongoDB也提供了json文档部分数据的本地更新操作,但是很多写操作并不是只是简单的原子操作,只是原子操作能用的时候最好去用.原子写操作一般使用排他锁实现,数据被读取时其他事务无法读取直到数据更新完成,这被称为cursor stability.不幸的是,ORM框架很容易使得代码不使用原子写操作而导致bug.
显示锁:当原子操作不合适的时候,可以采用显示加锁的方式.
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- Check whether move is valid, then update the position
-- of the piece that was returned by the previous SELECT.
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
自动检测丢失更新:无论是原子操作还是显示锁,都是通过将readmodify- write cycles顺序化执行.一种可选的方案能够使得他们并行执行,如果事务管理器发现了丢失更新,就回滚进行重试.结合快照隔离机制很容易实现这种检测,而且一旦数据库实现这种机制,不需要应用层做任何事情.
CAS:对于不提供事务的数据库,可以通过compare-and-set来避免更新丢失.每次更新时,必须确保上一次读到的数据没有被更改,如果被更改了那就需要进行重试操作.但是如果,数据库允许读取旧的快照,这就有问题了,所以采用时必须确保这点.
冲突解决和备份:在多副本的数据库里,丢失更新需要考虑另外的一些事情.锁和cas操作都只能适用于只存在单个最新副本的数据库.这时就只能允许存在数据的多个冲突版本,然后在客户端或者特殊的数据结构里进行解决冲突.而原子操作仍然有效(todo, 没太看明白为什么有效).常用的LWW(last write wins)实际上会导致丢失更新,但是仍然被广泛使用.
写倾斜和幻读
可以把幻读看作是丢失更新的泛化问题.此时两个事务读取了相同的数据,然后再更新数据(事务之间更新的不同的数据).而当更新相同数据时,就会导致脏写或者更新丢失.
- 原子操作没有用,因为原子操作是针对单个目标,而这里是多个目标.
- 快照隔离下的丢失更新检测机制也没有用,只有真正的可串行化才可以.
- 数据库是允许指定约束条件的,但是大部分数据库内置的约束条件,都不能包含多个目标.因此需要通过trigger或者物化视图来实现.
- 显示使用排他锁是可以的.
写倾斜例子
- 会议室预定系统:预定会议室前,你需要先查询有没有已经被预定,恰好这时候两个人都在预定,那么都一开始发现没有被预定然后就都进行了预定.
- 多人游戏:我们可以组织玩家同事移动相同的图形,但是我们没有限制玩家同事将不同的图形移动到相同的地方.
- 取名字:类似会议预定系统
- 双花:在记账系统里,可能会存在双花问题.
幻读的套路
幻读问题都有一个固定的模式来产生:
- 通过select根据某些条件来查询,是否一些条件被满足了.
- 根据查询的结果,程序继续下一步操作,提交还是取消.
- 如果程序决定继续运行,就会写入一些数据到数据库里然后提交.
第三步里的写入操作,其实影响了第二步操作的前置条件.也就是说,如果你重复第一步操作,因为第三步的写入已经改变了第一步的结果.事务里的写入操作改变了其他事务里的查询操作结果,这就叫做幻读.
物化冲突
对于上述模式里,如果第三步是修改操作,可以通过在第一步查询时加锁来避免这个问题.但是如果第三步是插入操作,这时第一步里是没办法去加锁的.这时我们需要人工引入锁.
比如,我们提前将可能的插入数据先插入到数据库里,然后将后续的插入操作改成更新操作.这种方式叫做物化冲突,但是如何去物化冲突很难而且容易出错,而且这种机制把解决并发的问题抛向了应用层,除非迫不得已,一般不会用这种方法.
可串行化
- 隔离级别很难理解,而且在不同的数据库中实现方式还不一致.
- 面对我们的程序代码,我们很难讲在一个特定的隔离级别下,它是否是安全的.
- 没有好的工具来帮助我们检测到竞态条件.
为了解决这些问题,终极方案就是可串行化隔离级别,这也是最强的隔离级别.它能够保证事务并行的运行,但最终结果和串行执行是一样的.这种级别下,数据库能够避免所有的竞态条件可能.尽管可串行化那么好,但是为什么我们不用他们呢?主要是因为性能问题,绝大多数数据库采用以下三种方案实现:
- 按顺序执行任务
- 两阶段锁
- 乐观并发控制
我们先从单机来看这个问题,后续再看多节点上的事务.
实际串行执行
最简单的避免并发问题的方式就是抛弃并发,任意时刻只有一个事务按顺序在单个线程里执行.尽管这个方法是如此的明显,但直到2007年左右才被认为是可行的.在过去的30年里多线程被认为是获取良好性能的必要条件,那么什么使得单线程执行成为可能呢?
- RAM开始变得足够便宜,于是我们可以大量使用RAM将数据存储在内存里.当所有的事务操作数据都存放在内存里时,事务就可以执行的飞快,而不用等待从磁盘加载数据.
- 数据库的设计者们发现OLTP事务通常只读写很小数据量的数据.同时长时间运行的分析查询都是典型的只读,可以只运行在一致性的快照里,而不用运行在线性执行的循环里.
设计为单线程执行的系统性能往往比支持并发的系统要好,因为避免了锁的协调开销.但是它的吞吐量受限于单个cpu核,为了重复利用单线程,事务需要区别于传统的形式来构造.
在存储过程里封装事务
早期数据库的事务倾向是,将用户行为的完整流程打包运行,整个过程在一个事务里然后原子提交,很清晰干脆.但是,用户往往不是那么快的做出决定和响应,因此一个事务对应一个HTTP请求.在交互模式下的事务里,很多时间都花费在应用层和数据库的网络传输上,如果我们取消了多线程模式,那么吞吐量将会糟糕透了,数据库大量时间都在等当前事务里的下一个请求,因此这种数据库必须提供多事务并发运行才能维持性能.
因此,单线程线程事务运行系统是不能运行这种交互式的多语句事务.需要应用提前将整个事务代码提交到数据库,这称为存储过程.
存储过程的缺点:
- 不同的数据库都有不同的存储过程语言,这些语言不同于开发者的编程语言,而且缺少基本的库.
- 运行在数据库里代码很难管理,相比于应用程序里的代码,很难debug,也比较难以维护版本和部署监控.
- 数据库比起应用服务器,要对性能更加敏感,因为一个单独的数据库实例要被多个应用服务器使用.一旦一个编写糟糕的存储过程在数据库里运行,造成的影响要被在服务器端造成的影响坏多了.
但是,现代存储过程都开始采用传统编程语言来编写了.使用存储过程和内存数据在单线程里执行全部的事务变得很合理.不需要等待I/O也避免了多并发控制策略的开销,能获得很高的吞吐量.
VoltDB使用存储过程来复制.它在每个副本上执行相同的存储过程,而不是把事务里的写操作从一个节点拷贝到另一个节点.当然这也需要所有的存储过程都是确定的,如果涉及到时间,随机数则需要特殊的API.
分区
但是单线程执行受限于单个CPU核的能力,为了解决这个问题,需要采用分区将数据分不到不同的CPU上.如果我们能找到一种分区方式,使得事务都是执行在单个分区上.只是一旦涉及到跨分区的事务操作,那么数据库就必须跨分区协调事务,而这些存储过程也需要在所有分区上步伐一致的执行,来确保整个系统的串行化运行.这样一来,跨分区事务的协调开销将导致系统很慢.
是否事务能够单分区执行取决于应用程序.简单的kv数据通常能够简单来分区,但是如果数据带有辅助索引,就需要跨分区的协调了.
总结
顺序执行事务在特定场景下可以成为串行化隔离级别的实现手段:
- 每个事务必须短小快速,因为一个缓慢的事务会停下所有的事务执行.
- 活跃数据被限制需要符合内存大小.很少被访问的数据可以被交换到磁盘上,但是磁盘上的数据被访问时,系统将会变慢.
- 写吞吐量被单个CPU核限制,或者可以将数据分区,限制事务在单分区上进行.
- 跨分区事务也可能,但限制很多.
两阶段锁
实现串行化最广泛的算法就是两阶段锁,两阶段锁和两阶段提交是不一样的.之前我们通过加锁来避免脏写,当事务并发写相同数据时,锁会等待第一个写操作所在事务结束了才会释放.两阶段提交里,写会阻塞读写,读也会阻塞写,这和快照隔离性的实现是不一样的.
- 如果事务想读取数据,必须在共享模式获取锁.其他事务被允许同时获取共享模式下的锁.但是如果有事务已经获取了数据的排他锁,则这些事务都要等待.
- 如果事务想要写入数据,必须获取排他锁.只有当数据上不存在任何锁的时候才能获取成功.
- 如果事务写读后写,那么需要升级共享锁成为排他锁.
- 事务获取锁后,直到事务结束都需要继续持有锁.两阶段的含义就是第一段持续获取锁,第二阶段释放所有锁.
由于大量锁的存在,导致很容易产生死锁现象.数据库能够自动检测死锁然后取消部分事务,被取消的事务需要应用程序去重试.
两阶段锁的性能
两阶段锁最大的问题就是性能问题.一部分是由于申请和释放锁的性能问题,但是更重要的原因是因为降低了并发度.传统的关系型数据库并不限制事务的持续时间,因为本身就是为交互式应用设计的,因此当一个事务需要等待另一个事务是,并不知道到底要等多久,一个慢的事务或者一个涉及大量数据获取大量锁的事务,会导致整个系统慢到不可用的地步.
死锁问题在基于锁实现的读已提交隔离级别下也会存在,但是在两阶段提交策略下尤其频繁.而当事务因为死锁而取消重试时,所有事务操作又要重新执行,一旦频繁产生死锁,大量无用功导致系统性能很差.
谓词锁
谓词锁和共享/排他锁有点儿像,但是谓词锁不属于特定的目标,而是属于符合查询条件的全部目标. 谓词锁的核心观点就是,可以锁定那些暂时不存在在数据库里的数据,但是将来这些数据可能会存在.这样包含谓词锁的两阶段锁就能避免各种形式的竞态条件问题.
索引范围锁
谓词锁性能不是特别好,当有许多活跃事务需要锁时,查询相应的谓词锁很浪费时间,因此数据库都改用索引范围锁,这是谓词锁的近似形式.索引范围锁不如谓词锁那么准确,可能会锁住更大的范围,但是消耗要小很多,这也是一种很好的权衡选择.如果找不到合适的索引,那么就会将整个表锁住.
可串行化快照隔离级别(SSI)
可串行化快照隔离级别只比快照隔离级别损耗很小的性能.
悲观与乐观的并发控制
两阶段锁是一种悲观的并发控制.而线性执行则是悲观到家了,将整个数据库用一把大锁锁住.而SSI则是一种乐观的并发控制方法,只有当事务提交的时候,数据库检测是否违反了隔离性,如果违反了那么事务就会取消或者进行重试.
乐观并发控制也不是一个新概念了.当数据库竞争比较激烈的时候,就会导致大量的事务取消,如果系统刚好接近最大吞吐量了,那么重试事务的负载就会让系统近乎崩溃.但是如果竞争没有那么激励,并且性能还有很多冗余,乐观并发控制性能还是很好的.
可交换的原子操作可以降低竞争:例如多个事务并发增加计数器,只要计数器内容不在同一个事务里读取,那么自增的顺序就不是很重要.
SSI实现是基于快照隔离,同一个事务中的读取操作都来自一个数据库的一致性快照.SSI增加了检测可串行化冲突的算法.
检测算法
当应用程序作出查询时,数据库并不知道应用逻辑会如何使用查询结果.为了安全起见,数据库需要作出假设,任何查询结果的更改都会导致食物中的写入无效.换句话说,事务里的读写之间可能会有因果关系.因此数据库必须检测出这样的场景,一个事务在一个过期的许可上进行了操作,那么需要被取消.那么数据库怎么知道查询结果变化了呢?
- 检测到针对陈旧mvcc对象版本的读操作(读之前,有未提交的写操作)
- 检测到写操作影响了前面的读取(读取之后有了写操作)
检测陈旧的MVCC读:数据库需要追踪由于mvcc可见性而被忽略的其他事务,当事务想要提交时,数据库需要检测下是否有之前被检测的事务已经提交了,如果有,那么当前事务需要回滚.通过避免不必要的取消,SSI保留了快照隔离性来支持长时间运行的读操作.
检测影响前面读取的写操作:数据库使用索引范围来追踪事务读取相关数据的操作,当事务结束后这些记录就被丢弃了.而当事务写入数据库的时候,会检测读取关联数据的事务对应的索引,然后通知相关事务之前的读取操作可能过时了.当事务提交时会查看影响自己的事务是否已经提交了,如果没有提交那么可以自行提交成功.如果已经提交了,那么就得选择回滚了.
SSI的性能
一如既往,算法的具体实现细节决定了实际的性能.例如,追踪事务的读写粒度需要作出权衡.如果粒度足够细就能够精确的控制事务的回滚,但是记录开销也很大.如果粒度比较粗,速度变快了但是可能会导致不必要的回滚.
对比两段锁,SSI最大优势就是一个事务不必被另一个事务持有的锁挂起来.读写之间互不影响,这使得事务延迟在很大程度上是可以预测的.只读操作不需要任何锁,因此非常适应于读很重的事务.
对比线性执行,SSI不局限于单个CPU核.
事务取消的速度很大程度上影响了SSI的速度.因此这就需要读写事务尽可能相当小.当然,就算事务比较慢,SSI也不像两段锁或者线性执行对这种事务那么敏感.
总结
事务是一种抽象中间层,能够将应用程序从并发问题和软硬件故障中解放出来.大量的错误最终简化成简单的事务回滚,然后应用程序可以选择重试.
注意数据库的隔离级别名称,可以看出来主要是针对并发读写操作来制定的.而针对并发写导致的问题,则不同的隔离级别的处理方式是不同的.例如对于脏写,各个隔离级别都能防止,而更新丢失,则不然.
隔离性主要解决的是并发问题,并发问题和隔离级别的对应关系
| 并发问题 | 描述 | 方案 | 隔离级别 |
|---|---|---|---|
| 脏写 | 一个事务复写了另一个事务尚未复写的内容,那么该事务回滚则另一个事务的写入丢失了 | 加锁,第二个写操作需要等待第一个写操作的事务完成了才能继续 | 几乎全部数据库都支持 |
| 脏读 | 一个事务读取了另一个事务尚未提交的写入,当前一个事务回滚,读取的数据就是无效的 | 保留新旧版本,读取始终读取最新提交的版本,新写入的版本提交后会覆盖旧版本 | 读已提交 |
| 读倾斜(不可重复读) | 事务在不同时刻读取的数据不相同,因为中间有事务写入提交了 | MVCC同时保留多个版本,然后索引采用写时复制的方式 | 快照隔离性 |
| 更新丢失 | 读取-修改-写入,基于读取的数据修改后写入,在不同事务里因为看不到对方的修改操作,因此会丢失前一个写入 | 自动检测机制,读取操作手动加锁,使用cas原子操作 | 部分快照隔离实现 可串行化级别 |
| 写倾斜/幻读 | 事务并发按照条件读取数据,然后根据读取的数据分别作出决定,并将决定写入数据库,写入后导致之前读取的数据集变化了 | 顺序执行事务,两段锁,SSI | 可串行化级别 |
并发读写隔离
|隔离级别|脏读|不可重复读
读倾斜|实现方案|
|-|-|-|-|
|读未提交|✔|✔|行级读写锁|
|读已提交|✘|✔|行级写锁(写阻塞读写,读不阻塞读写,但效率不可接受)
行级写锁(写只阻塞写)+新旧值
行级写锁+MVCC(退化每个查询生成一个快照)|
|可重复读(快照级别隔离)|✘|✘|行级写锁or乐观锁+MVCC(整个事务使用一个快照)|
并发写隔离
|问题|方案|隔离级别|
|-|-|-|
|脏写|行级锁|最基本功能,各种隔离级别都支持|
|更新丢失|原子操作:行级锁or单线程执行
显示加锁:数据库不支持原子操作,因此应用层手动加锁
自动检测更新丢失:检测到写冲突中断事务
CAS操作:需要确保读取不是读取的快照|