从事务ACID到分布式事务(一)
事务相信大家都不陌生,很多的数据库系统都提供了事务的支持,但并不是所有的数据库都提供事务保证(OLTP),例如对于OLAP系统的数据库,一般则不提供事务保证。但是目前已经有很多新型数据库同时支持OLAP、OLTP,比如TiDB,号称同时支持在线事务处理与在线分析处理 (Hybrid Transactional and Analytical Processing, HTAP) 的融合型分布式数据库。
事务:即要保证所有的读写是一个整体,要么全部成功,要么全部失败。提供的保证,便是大家熟悉的四种特性:原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Durability),简称ACID。
- 原子性:原子是不可分割的最小粒度。在计算机领域,原子操作意味着它只有两种状态,开始前的状态和结束后的状态,不存在中间状态。例如在Java多线程环境下,对某个变量进行i+1操作便是原子操作,CPU在寄存器中将数值加1,这是不可被打断的。但是 i = i+1操作便不再是原子操作,赋值操作则是另外一个原子操作,这已经是两个原子操作了,因此i++实际上便不是原子操作,这也是为什么多个线程同时对同一个i++操作不是线程安全的原因,实际上我们可以用锁或者是CAS操作来保证它的原子性。
- 一致性:一致性表示为对数据有特定的”预期状态“,任何数据的更改必须满足这些状态的约束。这种状态一般由应用层来保证,即定义一个恒等条件来执行事务,有些条件数据库可以检测,但有些却很难检测。比如张三转账给李四,那么他俩的账户总额在事务前后应当保持一致,这种数据库很难检测出来;再比如同时插入多条数据,数据的唯一ID字段应该要保证唯一性,如果发生冲突,数据库可以检测出来,并且后一个插入数据事务会失败。其它三个属性都是数据库自身的属性,而这个则是应用层的属性。
- 隔离性:任何系统都应该支持并发操作,对同一个资源进行写操作,对同一个变量赋值等等。数据库系统也是支持并发写,并发读。因此隔离性意味着并发执行的多个事务相互隔离,不能相互交叉。立马能想到的便是,事务依次串行执行,这就很好的保证了隔离性的例子。Redis的单线程特性,保证了操作一个一个执行,即使对同一个资源进行操作,也不会出现错误的结果。
- 持久性:一旦事务提交,即使存在硬件故障或者数据库崩溃,事务所写入的任何数据也不会丢失。一般我们认为数据写入到了硬盘或SSD中,数据便是持久化的。但实际上,这是在数据库层面而言的,如果一个硬盘彻底损坏或是被销毁了,任何力量也不能恢复数据,数据库就无能为力了,因此并没有完全的“持久性”。为了保证我们想要的数据尽量不丢失,因此需要备份,数据复制等等技术,这就是另外的话题了。
平时开发中,只要简单的Begin Transaction到最后Commit,中间所有的操作都是事务。数据库帮助我们屏蔽了底层事务所有的细节和复杂性。比如,为了保证原子性和持久性,其中需要涉及到Committing Log、Redo Log等技术细节,为了一致性,数据回滚则涉及到Undo Log,这些技术细节并不是本文的重点。
首先事务中要详细讨论的是隔离性以及相关的隔离级别。如果两个运行的线程,它们永远不访问相同的资源,就不需要锁同步操作,如果任意两个事务读写操作,永远不访问相同的数据行,那也不需要隔离,实际情况,这是不太现实的。事务需要隔离,就像并发代码需要访问临界区一样,想要让它们互斥,就不得不使用锁!
隔离性
在大部分的教材或者介绍事务的文章中,都会使用转账这种场景来说明,这里我就也照用这种业务场景来说明隔离性的四种级别,希望能让大家明白隔离级别的原委,以及其中锁的使用。
首先,我们虚拟一个小A同学,我们的小A同学是位品学兼优的优秀大学生,因为家庭条件不太好,所以在大学期间,他不得不勤工俭学,并且校外帮助一些中学生担任课外辅导工作,争取一些外快。
完全不隔离
到了月底了,外快收入和勤工俭学的收入同时打入他的银行卡,不出意外的话,他这个月应该有400块的收入。
-- 外快收入 Begin Transaction update bank_account set balance = balance + 100 where account_name = '小A'; -- 下面的更新在这个时机发生 Commit -- 勤工俭学收入 Begin Transaction update bank_account set balance = balance + 300 where account_name = '小A'; Commit;
很不幸,并发的事务导致后面的更新操作覆盖了前面的更新操作,小A最终却只收到了300块,这就出现了脏写(两个事务并发修改同一个数据,如果后写的操作覆盖较早的写入,但是先前的写入还未提交)。这种场景就像上述的两个线程同时对一个变量++操作。这种连最基本的原子性都被破坏了,所以完全不隔离是不可行的。因此我们需要加上写锁(或者叫独占锁),写锁会维持到事务一致结束,以此来保证至少两个事务更新操作或是写操作隔离。
读未提交
根据上述情况,我们加了写锁之后,对同一行数据更新操作就顺序执行,最终小A顺利得到了400块收入,此时我们的隔离级别称之为读未提交。为了让心里踏实一点,小A决定再查一下帐号余额,但与此同时,学校准备预扣下学期的学费200块。
-- 扣学费操作 Begin Transaction update bank_account set balance = balance - 200 where account_name = '小A' and balance > 200; -- 在未提交之前,下面的查询操作在这个时机 Commit -- 查询余额操作 Begin Transaction select balance from bank_account where account_name = '小A'; Commit
此时小A查询的余额是200块,他当时傻眼了,明明应该是400块的。即在几分钟之后,他手机收到了学校预扣费的短信,他才明白怎么回事,但是这里的读操作,读到了一个还没有提交的结果,虽然最终结果是一致的。
但假设学校扣费的操作在小A读取之后回滚呢:
-- 扣学费操作 Begin Transaction update bank_account set balance = balance - 200 where account_name = '小A' and balance > 200; -- 在未提交之前,下面的查询操作在这个时机 ROLLBACK -- 这里回滚 -- 查询余额操作 Begin Transaction select balance from bank_account where account_name = '小A'; Commit
因此小A查询的余额是200块,可能过一会他查询又变成400块,同样他还是傻眼了。
我们把这种读写并发情况下,读操作读取了一个还没提交事务的更新或写入的场景称之为脏读。我们可以看到,读未提交存在脏读的情况。
读已提交
为了避免数据脏读,那只好对读取也加锁了,虽然损失了性能,但为了让小A不至于傻眼,这也是值得的。这里加的锁我们称之为读锁(或者叫共享锁),读取并不涉及到更改,所以读取完成就可以释放锁。读锁和读锁之间并不互斥,读锁和写锁之间互斥。扣学费的事务在记录上加了写锁,因此后续的读取操作附加了读锁,但由于读写互斥将会导致读取阻塞,所以读写也得到了顺序的执行。
就这样,小A始终看到了账户里正确的结果,目前来说一切还都不错。我们现在来把上面的扣学费场景稍微做个变形:
-- 扣学费操作 Begin Transaction update bank_account set balance = balance - 200 where account_name = '小A' and balance > 200; Commit -- 查询余额操作 Begin Transaction select balance from bank_account where account_name = '小A'; -- 上面的扣学费操作并且已经提交的时机,在这两个读取操作之间 select balance from bank_account where account_name = '小A'; Commit
结合已经说明的读写锁,认真思考下这两个读取的结果是什么呢?
第一个读取是在扣费事务之前,加上读锁之后再读取,所以它读取到的是400;紧接着扣费事务开始获取了写锁,提交之后,写锁释放了;后续的第二个读取在写锁释放后,便读取到了200。
同样一个事务,两次对于相同的数据行的读取,却读到了不同的结果,这种情况我们称之为不可重复读。所以,读已提交还是有不可重复读的问题,即便我们加了读写锁,但因为它读取了已经提交的值,虽然这个值确实是最新的值。如果要避免这种情况,我们需要进一步收紧隔离性。
可重复读
让我们仔细思考下,怎么解决不可重复读这个问题?上面的一个事务内读取到了两个不同的值,是因为我们读取了一个提交的值,事务在读取上还是有交叉。那么这里我们把读锁的范围扩大,扩大到整个事务级别,再用上面读提交的例子:
-- 扣学费操作 Begin Transaction update bank_account set balance = balance - 200 where account_name = '小A' and balance > 200; Commit -- 查询余额操作 Begin Transaction select balance from bank_account where account_name = '小A'; -- 上面的扣学费操作并且已经提交的时机,在这两个读取操作之间 select balance from bank_account where account_name = '小A'; Commit
因为现在读锁是持续到整个事务结束,因此两次读取的结果就是一致的了(都是400)。
串行化
现在有这样一个需求,学校财务想统计下已经预扣费成功的学生人数,此时小A刚好扣费成功,往表中插入了一条:
-- 统计扣费成功人数,从学生表中统计,并且执行了两次 Begin Transaction select count(*) from paid_students; -- 下面的事务在这个时机,往表中插入了一条数据 select count(*) from paid_students; Commit Begin Transaction insert into paid_students(name,tuition) values('小A',200); Commit
上面的例子,可以看到统计的结果是不一样的,因为此时对同一个对象加读写锁已经不再生效。我们把这种一个事务中的写入改变了另一个事务查询结果的现象,称为幻读。要解决这个问题,我们需要在count(*)计算时,禁止对表进行插入修改操作,这里需要用到范围锁(对于某个范围内直接加独占锁)。常见的例子就是Select for update语句:
-- 它将符合条件选中的行进行加锁处理 select * from paid_students where age > 20 for update
我们也将这种提供了最高隔离级别称之为串行化。事务就好像是在依次执行一样,但并不是一定要依次执行,而是和依次执行的结果一致。这里我们暂时忽略实现串行化的复杂性,有兴趣的可以参考两阶段锁(2PL)
到目前为止,我们已经可以做个小结了,隔离级别的提高,是随着锁(读锁,写锁)的升级来实现的。同样的,锁的开销意味着性能的下降;同时也意味着死锁发生的频率增加,有时死锁不得不人为介入才能消除。
MVCC
上述我们讨论了很多的读写冲突的例子,从而引出了加锁来实现隔离性,而加锁又引入了新的诸如性能等等一系列。那有没有一种方案又不用加锁,又能实现上述的隔离性呢?
常见的一个办法是,我们让每个事务都始终从一个一致性的快照上读取,事务最开始看到的数据总是最近提交的数据,即某一时刻点冻结的一致性快照。这样的好处便是事务读取上不需要加任何的锁,并且读不会阻塞写,写亦不会阻塞读。考虑到多个事务并发执行,因此数据库可能会保留多个不同的提交版本,这种技术称之为多版本并发控制(MVCC)。
MVCC遵循以下原则(来自维基百科):
- 事务Ti读取对象(P)时,只有比事务Ti的时间戳早,但是时间上最接近事务Ti的对象版本可见,且该版本应该没有被废止。
- 事务Ti写入对象P时,如果还有事务Tk要写入同一对象,则(Ti)必须早于(Tk),即 (Ti) < (Tk),才能成功。
可以看出,可见性和写入都要严格遵循版本高低来控制。
有了这种方案,我们将上面读已提交方案也稍微优化一下。上面实现读已提交隔离,是施加读锁方案下来处理,如果一个长时间的写操作事务,将严重阻塞读操作,在频繁读的业务场景下,这将会引起连锁反应,整个系统的响应延迟会非常大。我们将这个加读锁的方案改为如下:数据库只需要维持其已经提交的数据的旧版本和未提交数据的新版本两个值,当新版本数据提交后,才切到读最新的值。
多版本并发控制可以用于实现读提交和可重复读两种隔离级别。不同的区别是:读提交情况下,对每一个查询单独创建一个快照;可重复读隔离级别下,整个事务都运行在一个快照下。
我们利用这个MVCC实现的可重复读级别隔离的数据库,再回到上面描述幻读那一小节中,我们提到幻读的统计人数的例子,利用快照隔离的规则可知,我们已经没有了例子中幻读的问题,但是我们的隔离级别只是可重复读。
我们利用这个MVCC实现的可重复读级别隔离的数据库,我们再来继续讨论如下另外一个更加微妙的写写冲突情况:
小A同学开发了一个游戏网站,它允许多人在平台上开展联机游戏,首先用户需要注册一个帐号,并且这个帐号必须是唯一的,这样才可以准确标识某一个用户,此时恰好有两个用户同时注册同一个帐号::
-- 用户1创建帐号操作 Begin Transaction select count(*) from account where account_name = '小A'; -- 根据第一个的结果来插入一个帐号,如果已经被占用则提示“帐号已被注册” insert into account(name,time) values ('小A','2021-01-01 12:00:00'); Commit -- 用户2创建帐号操作 Begin Transaction select count(*) from account where account_name = '小A'; insert into account(name,time) values ('小A','2021-01-01 12:00:00'); Commit
根据已有的快照隔离的知识,两次事务都是运行在各自的一致性快照中:
- 用户1创建帐号的事务先开始,假设事务ID是11。
- 用户2创建帐号的事务随即开始,事务ID为12。
- 根据MVCC原则,较晚事务ID所做的任何更改都是不可见,不管它有没有提交。因此不管事务12是否提交,它对于事务11都是不可见,因为它晚于事务11。因此就算事务12先提交创建帐号,事务11第一步的判断依旧是可以成立的。
- 同样的,对于未提交的更改,一致性快照中也不会表现出来,因此只要在事务12开始时,事务11没有提交,那么不管事务11执行在哪一步骤,更新对于事务12都是不可见,就此可以知道,事务12的第一个步骤判断同样可以成立。
综上所述,不管怎么样,我们在判断是否用户已被注册之后,同时插入了两条相同的用户,这肯定是不符合系统的设定的。这种情况我们一般称之为写倾斜,事务根据第一步查询条件去作出某种决定修改数据库,但是在提交时,实际上先决条件已经不成立。它是由于幻读而导致的写倾斜现象。 MVVC可以很好的处理读与写的冲突,并且几乎无锁处理,但是对于这里描述的写写冲突,锁基本还是唯一的出路。
最后我还要提一点,隔离级别和相应的问题并不一定是对应的, 而且每个数据库对隔离级别的实现都是有差异的,我们在使用一款数据库时,都应该做完备的测试,以及对其隔离性有充分的了解,这样才能避免出现不必要的Bug。