ACID ,你还在为了八股文而背吗

欢迎关注我的公众号:技术小白成长记

背八股文或者面八股文的时候,ACID是一个绕不开的问题,一谈到数据库或者事务的特性就是ACID,你有想过为什么是ACID吗,除了按照八股文的套路回答下ACID的含义,A代表原子性,C代表一致性,I代表隔离性,D代表持久性以外,有认真想过这几个特性的含义以及联系吗?

ACID ,在数据库中主要是基于事务来进行讨论的。

事务是什么呢?可以理解为是一种抽象,是一组操作的集合。类似于方法,单条语句也可以执行,但是我们更建议封装在一个方法中,通过调用方法的形式来执行这一句代码。不过,事务与单纯的一组操作不同的地方在于,事务要满足一定的特点,事务有着更强的约束性,这个约束简单来说就是 ACID。

A (Atomic) 原子性

A Atomic 原子性。通常表示一个操作不能再继续划分。原子不可分割,这句话在以前来看没有错的,但是从近代物理学来看,原子由质子、中子和电子组成,实际上是可以继续分下去的。

如果我们认为一个操作或者一组操作是原子的,那么操作该具有什么特性呢?

  • 操作不能被打断,不能执行到一半就不执行了。
  • 操作不可分割,操作要么成功,要么失败,成功即所有步骤都成功才算成功,部分失败或者整体失败都算失败,不允许出现部分成功,部分失败

在并发编程里,也常常听到原子性,这两者有什么关系吗?

以Java 并发编程为例,在多个线程并发操作同一个变量的时候,由于我们的操作并不是“原子”的,CPU会进行线程切换,CPU就像是一个忙碌的厨子,在同时炒多个灶台,在一个灶台前翻几下锅,然后赶紧切换到另一个灶台,不会等到一个灶台的菜都炒好了再去另一个灶台,它总是并发的炒多个灶台。

我们在进行编码时,一句代码就是一个最小的基本单元,但是当CPU真正执行代码的时候却不是以一句代码为一个最小单元来执行的,比如执行:count += 1 就包含了三个步骤:

  1. 把变量 count 从内存加载到 CPU 的寄存器。
  2. 在寄存器中执行 +1 操作。
  3. 将计算结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

线程获得了CPU时间就可以开始执行代码了,执行代码时,CPU有可能在上述任意一步执行完毕以后发生调度,切换到其他线程去执行其他操作,暂停当前线程的操作。

这会导致什么问题呢?比如我们用一百个线程对count变量执行加1操作(初始值为0),初衷是每个线程都执行加一操作,那么结果就是100。但实际上count的值会小于100。线程1开始执行时,读取count = 0,然后执行加1,加和后的值为1,正准备把加和后的结果写回去的时候,CPU被调度到线程2执行,由于线程1还没有把计算后到值写回去,因此线程2读取到的count还是0,线程2执行加1操作,然后把加和后的结果写了回去,此时count的值被线程2修改为了1。但当线程1又重新获得了CPU时间,把1又写回给了count变量。

线程切换示意图
img
问题是什么?线程1计算count的值时,读到的count的值为0,进行加1操作,结果为1。但是写回去的时候,因为count的值又被其他线程修改了,此时count的值已经为1了,那么线程1对count 进行加1然后得到1的这个结论就是错误的,最新的count值为1,进行加1操作后结果应该为2。

这个时候,为了并发的安全性,我们就会考虑加锁。对count变量的访问进行加锁以后,即便发生了线程切换,也只有获得了锁的线程才会去修改该变量。

1. lock.lock();
2. try {
3.     count++;
4. } finally {
5.     lock.unlock();
6. }

如下图所示,加锁以后,尽管thread1在执行count++的时候发生了线程切换,到thread2被调度执行时,由于thread1没有释放锁,thread2也没有办法去修改count变量。

而在数据库中,我们关心的原子性却并不是为了并发安全性而考虑的,我们讨论原子性更多的是为了考虑操作失败以后该怎么办?

在数据库中,我们很少会关心多个线程update同一条数据的时候会不会出现并发安全问题,因为数据库已经用锁帮我们解决了这个问题,我们考虑的是更进一步的事,考虑的是如果更新失败了该做什么操作,如果是部分更新成功,部分更新失败该怎么做?

这个时候,数据库就会以原子性作为唯一准则。我们认为事务整体就是一个原子操作,作为一个原子操作,整体就是不可分割的,操作要么是成功,要么是失败,不允许存在二义性。

  • 成功,是整体成功,所有操作都需要成功,然后 commit 提交。
  • 失败,不管是整体失败还是部分失败都会认为是失败,这时候就会 rollback,回退到执行操作前的状态。

而在并发编程中,我们对于一组操作部分成功,部分失败,究竟是该commit还是rollback,并没有一个明确的约定,一切取决于你的代码。

相比于并发编程中的原子性,数据库中讨论的原子性更偏向于应用层面,抽象程度更高,并发编程中的原子性更加底层,更加裸一些,就像是JDBC和Mybatis的关系,Mybatis在JDBC上提供了更进一步的封装,屏蔽了更多底层的细节。

I (Isolation) 隔离性

在原子性中我们讨论了数据库中的原子性和并发编程中的原子性的一些区别,数据库中的原子性并不是为了并发。那ACID中哪个特性是与并发相关的呢?答案就是隔离性。

无序列表

隔离性四个级别,从低到高分别是:

  • 读未提交,Read Uncommitted,这是最低的隔离级别,一个事务可以读取另一个事务没有提交的结果。
  • 读已提交,Read Committed,这是大多数数据库默认的隔离级别,一个事务可以读取另一个事务提交的结果。在读以提交的隔离级别下能解决脏读的问题。
  • 可重复读,Repeatable Read,MySQL中默认的隔离级别,有时也称为快照读,一个事务只能读取事务开启前提交的结果。在可重复读下能解决不可重复读的问题,但是不能解决幻读,如果要解决幻读,还得再使用间隙锁。关于脏读和幻读,可以参考我的另一篇文章:再探幻读!
  • 串行化,Serializable,这是最高的隔离级别,同时也是性能最低的隔离级别,虽然整体看起来是在并发执行多个请求,但这些请求实际上是在内部进行排队,然后一个个进行执行。

在MySQL中可以通过如下命令查看当前的隔离级别。

mysql> show variables like '%isolation%';
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
| tx_isolation          | REPEATABLE-READ |
+-----------------------+-----------------+
2 rows in set (0.00 sec)

mysql> 

隔离性决定了在并发的情况下,当前事务能看到什么样的数据,进而决定了能得到什么样的结果。

img

在读已提交的隔离级别下考虑上图所示场景:

Alice 在银行有1000美元的储蓄,分为两个账户,每个500美元。

Alice先查看了账户1,看到了该账户有500美元,符合她的预期。

现在一个事务从账户2中转移了100美元到账户1。

然后,Alice再查看账户2中的余额,看到该账户有400美元,在Alice不知道另一个事务行为的时候,Alice 很纳闷,自己的账户总余额是1000,但现在两个账户的余额加起来却只有900,看起来自己的账户好像丢失了100美元。

最后,当 Alice再次查看自己账户1的余额的时候,发现账户1的余额是600元,这个时候她可能才会明白原来钱没有丢,只是从一个账户转化为另一个账户里。这个问题,称为不可重复读,同样的条件,两次读取到的结果不一样,在这个场景下,不可重复读或许能被接受,但是在有的场景下,这样的问题就不能被接受,就得选用另一个隔离级别:不可重复读了。

那数据库的隔离级别是怎么实现的呢?

  • 读未提交就不用说了,不提供任何保证。
  • 读已提交,使用行锁来避免脏写。当事务想要修改特定对象(行或文档)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁,如果另一个事务要写入同一个对象,则必须等到持有锁的事务提交或中止后,才能获取该锁并修改特定对象。
  • 可重复读,通过MVCC多版本并发控制机制来实现。在创建表的时候,除了我们指定的字段以外,还会有一些隐藏的字段。表中的每一行都有一个 created_by 字段,其中包含将该行插入到表中的的事务ID,事务ID是随着时间递增的,ID值越大,表示该事务越新。此外,每行还有一个 deleted_by 字段,最初是空的。如果某个事务删除了一行,那么该行实际上并未从数据库中删除,而是通过将 deleted_by 字段设置为请求删除的事务的ID来标记为删除。在稍后的时间,当确定没有事务可以再访问已删除的数据时,数据库中的垃圾收集过程会将所有带有删除标记的行移除,并释放其空间。在开启一个事务的时候,通过每一行的事务ID来决定哪些数据能在当前的事务中被看到。
  • 可串行化。在实践中很少用到。避免并发问题的最简单方法就是完全不要并发,在单个线程上按顺序一次只执行一个事务。这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。

D (Durability)持久性

数据库系统的目的就是提供一个安全的地方存储数据,而不用担心丢失。持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。

但即便数据已经写入日志文件,已经写入磁盘,甚至已经备份到多个机器。也不能说数据就百分百可靠了,完美的持久性是不存在的,我们只是尽可能的去保证持久性,尽可能保证数据不会丢失。

而在数据库中,谈到持久性就离不开数据库的日志系统。以MySQL为例,有两种值得注意的日志:binlog和redolog。当在内存中更新值以后,将更新分别记录在redolog和binlog以后才会commit,当将数据的更新写入到这两个日志文件以后,就认为数据已经保存完毕了,后续再异步将更新的数据写回磁盘,即便写回磁盘的过程发生崩溃,也能很快的从日志文件中进行恢复。

C (Consistency) 一致性

C Consistency 一致性。我们在看小说或者追剧时,对于一部好的小说,我们可能会这么评价:这部小说写得一气呵成,非常连贯,剧情安排合理。

什么是连贯性呢?剧情不拖沓,安排有理,前面的章节看到了很多伏笔,后面章节一一应验,让你看完直呼卧槽,原来是这样。

看一些网文小说时,往往会介绍主角的凄惨背景:家族废物,不堪一击,众人欺负,惨遭退婚。如果没有这些铺垫作为原因,上来就是主角三十年河东,三十年河西,莫欺少年穷,你看了应该是一脸懵逼吧。

回到文章,我的理解一致性就是需要满足某种因果关系,使其具有合理性,果由因生,有始有终,不能戛然而止,也不能无疾而终。以转账为例,a向b转了100元,a的账户里会减少了100元,b的账户里应该增加100元。不应该出现 a的账户余额减了100元,但是b的账户余额却没有增加100元,也不应该出现a的账户余额没有变,b的账户余额却增加100元来自于a的转账汇款。a的账户余额减少100是因,b的账户原因增加100是果,由因必有果,不能出现意料之外的事,意料之外的事会让一切都显得那么突兀,毫无条理,也就失去了一致性。

这也是为什么最后才能谈一致性的原子性。虽然一谈数据库的特性,我们就说是ACID,但仔细想想,数据库能提供的特性其实只有A、I、D,是需要通过 AID来达成C。

AID才是数据库的特性,是数据库提供的保证,通过这些保证来达成C。那问题来了,一致性一定是能保证的吗?其实也未必。

比如读已之写这个一致性保证,读已之写什么意思呢?我要能读取到数据库中最新写入的数据。

乍一听,这不是废话吗,这不是理所应当的吗。我要是不能从数据库中读取到我刚写入的数据,我还要数据库干嘛?这就有点像刚学并发编程时,听到 happen-before 这一套理论一样。

放在单机的视角或许的确是这样,但是放在分布式系统中,读已之写这样的一致性还真不一定能保证。在分布式系统的一致性中,比如CAP理论,C(Consistency)一致性,A(Availability)可用性,P(Partition Tolerance)分区容忍性,即允许网络存在分区,因为系统无法保证百分百不出问题,尤其在分布式系统中,出问题的几率更大,几个分布在不同地区的机房因总总原因数据没办法同步的事情时有发生,因此,对于分布式系统而言,P通常是必选。

CAP理论描述了这么一个事实:在一个互相连通,存在数据共享的分布式系统中,如果出现了系统异常导致各个分区的数据无法同步的时候,也就是在P发生的时候,我们只能在A和C中进行二选一,要么是选AP,要么是选CP,不可能同时满足AC。

这也就意味着,一致性在有的场景下,并不是必须的,至少在系统出问题的这一段时间范围内,需要在一致性和可用性之间做选择。

如下图所示,当用户往购物车中添加了商品,此时的商品数据存放在一个数据中心,当用户下一次进行访问时,由于访问的可能不是同一个数据中心,两个数据中心的数据还没有完全进行同步,或者是因为系统出现了异常而同步中断了,就会导致用户访问DataCenter2时,就看不到刚添加进购物车的商品。如果我们要选择一致性,当数据没有同步时,系统就不提供任何服务,当用户访问时,直接提示用户购物车不可用。如果要选择可用性,当数据没有同步时,提示用户数据未更新,但是用户还是可以继续使用购物车。在通常情况下,我们都会选择后者。

在这里插入图片描述

如果选择了AP,那是不是就说明C就不要了,当然不是。首先,之所以要做这样的选择是因为系统出问题了导致了网络分区,但是系统出问题只是偶发事件,系统大部分时间都是正常的,在正常的情况下,就可以同时拥有AC。当系统产生网络分区P时,我们为了可用性而牺牲了一致性,这只是暂时的,当系统正常的时候,我们还是要通过各种补偿手段来恢复一致性C。

CAP理论太过于绝对和理想化,这样就有了稍微松一些的BASE理论。如果我们选择了AP,那么当系统恢复正常以后,我们要通过各种手段来恢复C,经过一段时间后,数据最终达成一致。即便我们根据CAP理论选择了CP,系统就时时刻刻都能保持一致吗?答案也是未必的,考虑到在一个分布式系统中,数据可能分布在各个地区,数据同步的网络开销是不可以忽略的,肯定会出现在同一时刻数据不同步的情况。即便是在同一个机房,同步数据也需要时间,只是这个时间我们在系统应用中通常能忽略。不能数据时时刻刻保持一致,我们退而求其次,在实际应用的时候,只需要保证最终数据能达成一致即可,至于最终究竟是指多长时间,就得看应用可接受的范围了,如果应用承诺在在明天12:00前数据会一致,那么数据最长不一致的时间也就能到明天12:00之前。

如下图所示,当A向B的账户中转了一笔钱以后,因为数据存储在不同的分区,B什么时候能查询到最新的余额就得看系统承诺的最终一致性的时间是什么时候了,系统承诺的是立即到账,那么用户B可能马上就能看到更新后的余额,如果承诺的是一天内到账,那么用户B可能到第二天才能查询到最新的余额。

img可以看到,一致性这个概念在几乎所有的系统中都会被提到,它表征着一个系统是不是正常合理的工作,数据从系统的入口流入,再从系统出口流出的时候,输入输出是否能符合预期,如果能符合预期,那就说明系统是具有一致性的。

但合理性的定义是存在差异的,比如在传统的关系型数据库中,事务只有成功或者失败这两个状态,事务失败了就会回滚到执行前的状态,不允许存在部分成功,部分失败这种状态,这被定义为不合理的。而在一些No-SQL数据库中,是不会强行做这种约束的,除了成功或者失败还会存在第三种状态,并不强行要求失败了就得回滚,至于在这样的情况下该做出什么样的处理,这不是数据库该关心的事,是应用程序要关心的。我们可以认为,前者的合理性要更加严格一点,提供的是更严格的一致性,后者的合理性更加宽松一点,提供的是弱一些的一致性。就像一个是严父,一个是慈母。

参考文章

数据密集型应用系统设计

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值