我想你在使用数据库的时候,心里会假定这里面的数据都是 100%准确的。回想一下,你在工作中有没有这样做过:
有人给你反映了一个问题,说数据错了,你的自然反应是去检查代码有没有问题,而不会想到去确认数据库有没有问题?
为了更快更方便地执行单元测试,你认为通过 Mock 数据加上断言(assertion)来代替数据库中实际存储的数据是完全没问题的。
如果你这样做过,或者有过这样的看法,那你一定是在假定:数据都是 100%准确的。
今天我们不妨来思考下,数据库为什么会使你有这样的认知?是因为数据库的开发团队对其测试到位吗?我想,真正起到决定性作用的是数据库背后的设计理念 ACID,这就是我们今天的主题。
什么是 ACID?
ACID 是原子性(Atomicity,或称“不可分割性”)、一致性(Consistency)、隔离性(Isolation,又称“独立性”)、持久性(Durability)的首字母简称。Andreas Reuter 和 TheoHärder 这两位前辈在 1983 年提出它,指出一个数据库“事务”只要满足这 4 个特性,在任何情况下数据都能保证准确。
“事务”是数据库的执行单元,除了我们平时用显式声明的 transaction 之类关键字包裹的代码外,每一条单独的 SQL,也是以事务的形式执行的。比如,当你在一条 SQL 中同时 insert 多笔数据的时候,一旦发生异常,所有的这几笔数据最终都不会被插入到目标表中,会一并撤销。
在保证达到这个效果的过程中,ACID 的四个特性分别起到了什么作用呢?
1. 原子性
一句话来概括原子性,用于保证每个事务被视为单个完整的个体,不可分割。满足原子性的事务,要么完全成功,要么完全失败,不允许存在其他中间状态。通常这点指的是我们同时执行多条 SQL 语句的时候,可以将这些 SQL 语句的生效与否捆绑到一起,以保证最终要么全部数据被更新到数据库,要么全部都不更新到数据库。我们来看一个例子。
小明让小王代购了一些东西回来,需要在微信上支付给他 1000 元。当小明输入完金额点击“确认转账”之后,执行的 SQL 至少是这样的:
注意,这两条语句中只要任意一条执行失败,而另外一条执行成功,那么从原子性的要求来说,所有执行成功的修改都需要一并撤销,恢复到最初的状态,这个撤销操作我们称为“回滚”。否则,微信体系中的总余额会无故多出或少了 1000 元。
数据库中原子性的主流实现方案是通过日志来做的,每一次操作数据前都会先将当前数据记录到日志中,这样在需要回滚时,我们只要把 Undo Log 中的数据拿出来还原,就可以撤销已经执行成功的操作。
原子性是四个特性中最核心的一个,仅关注当前的这一次操作,不考虑是否存在其它的什么操作。
2. 隔离性
在上面小明和小王的故事中,如果再出现一个人小张,他也让小王代购了东西要付钱,会出现新的情况,如下图。
注意一下红字部分。我们发现,这个时候哪怕两次转账的事务分别保证了原子性,并且执行成功,最终的结果还是有可能出错。
上图中的现象,我们称为“丢失更新”(Lost Update)。当然,还有其他可能产生的现象,比如脏读、不可重复读、幻读,等等。不过,我们暂时不需要过多纠结于这些现象,你只要记得:当仅满足原子性的前提下,如果遇到并发执行,依旧会出现数据错误。
所以,这时候我们需要通过隔离性的指导来避免这些问题。隔离性本质上指导解决的是一个资源竞争问题,通俗点说,就是多个事务并发执行后的状态,应该和它们串行执行后的状态是一致的。
在数据库中解决资源竞争问题与其它软件系统无异,就用锁。在数据库中对锁的运用不同,因此产生了不同的隔离级别,不同的隔离级别对应解决的是前面提到的这些异常现象。如,读未提交(Read Uncommitted)解决了丢失更新,读已提交(Read committed)多解决了脏读,可重复读(Repeatable Read)又多解决了不可重复读问题,最高级别的可序列化(Serializable)解决了全部这 4 个问题,即丢失更新、脏读、不可重复度、幻读。
其实在实际的运用中,遇到的场景会更复杂,所以詹姆士·格雷(Jim Gray)等人在 1995 发表了论文“对 ANSI SQL 隔离级别的批评(A Critique of ANSI SQL Isolation Levels)”将上表做了扩充,增加了游标稳定(Cursor Stability)和快照隔离(Snapshot Isolation)隔离级别,指导我们在做隔离时,可以为获得更好的性能进行一些新的尝试。
3. 持久性
当你使用一些云产品写文章的时候,洋洋洒洒写了几千字,安心睡觉去了,第二天起来发现内容停留在刚起笔的那几个字。任何的数据变更完成之后,就相当于成为了“历史”,需要保存下来才能为未来所用。因此数据库需要具备持久性,才能为我们所依赖用于存储数据。
如今,我们几乎都是利用硬盘作为数据库的存储介质,来保证持久性。那么理论上,除非硬盘本身故障,否则都不应该出现这样一种情况:一条 SQL 变更成功后,发生数据丢失或者数据回到更早的状态。
另外,因为所依赖的存储介质本身也可能出现故障,所以我们可以通过将相同数据冗余存储在多个存储介质上,并同时提供读写服务。冗余越多,越无限接近 100%的持久性效果。
4. 一致性
一致性的含义其实很简单,就是最终结果的对与错,是否是你所希望的结果。任何系统如果无法确保产生的数据结果与预期一致,那么整个系统其实是没有价值的。
回到前面小明和小王的例子。只要小明账户少了 1000 元,小王账户必须要多出 1000 元,这才是我们所希望的结果,否则都是错的,也就是“不一致”的。
那么你可能会问,这么一说,一致性和原子性意思好像差不多啊?关于这点你可以这样来理解:
原子性关注的是关系和过程,确保指定的 SQL 之间是一个命运共同体;比如鸡蛋孵小鸡这个过程,必然是鸡蛋破了后小鸡再出来,而不是鸡蛋破了,小鸡不见了或者鸡蛋没破,不知道从哪哪冒出来个小鸡。
而一致性关注的是结果,这个结果的预期是你来定的,如何达到这个结果的过程并不是它所包含的概念。还是鸡蛋孵小鸡这个事,比如你预期一个鸡蛋里只能孵出一个小鸡,那么如果最终 9 个鸡蛋里出现了 10 个小鸡,这时就是不一致的。
由于一致性只表示一个结果,它只是指引出一个正确的工作方向。而要达到这个正确的结果并不完全是由数据库保证的,它只是一个按规则办事的“监督者”。但是,它提供了主键、外键、约束、字段类型等,让你可以在不同层面上定义什么是“一致”。一旦不符合你的定义,数据库就会抛出异常来提醒你,这里不符合你的预期了。
清楚了这 4 个概念,我想有必要将它们联系起来,看清它们之间的依存关系,以对四个特性有一个整体的认识。
ACID 间的联系
在我的理解中,原子性(A)、隔离性(I)、持久性(D)是为达到一致性(C)而存在的。
可以理解为,只要满足了原子性(A)、隔离性(I)、持久性(D)那么数据存储层面的一致性(C)自然也就满足了。
不过,站在一个完整的系统角度来说,要达到真正的一致性,还需要我们在 Coding 的时候有意识的去定义达到“正确结果”的代码逻辑。
为什么要聊 ACID?
聊 ACID 的原因是分布式系统中的一个经典定理——CAP。CAP 是指导我们进行多进程之间交互的设计理论,告诉我们该如何去权衡一致性(C)、可用性(A)、分区容错性(P),这也是它这三个字母所表达的含义。
我想,如果你知道分布式系统理论中的 CAP 定理,肯定会好奇 ACID 和 CAP 两者定义的“一致性”表示的是不是同一个意思。其实不是:
描述的主体不同,ACID 中的 C 指的是数据库事务的一致性,而 CAP 中的 C 指的是程序之间请求的一致性。
对结果的定义也有些差异,CAP 中的 C 除了一致性之外还带着一些原子性的意思,一次操作中产生的多个请求要被视为一个完整的个体,不可分割,这个特点和数据库中的原子性是一致的。
所以你会发现,CAP 定理中所表述的“一个请求”类似于数据库 ACID 中的“一条 SQL”,并且还保留了原子性和一致性的含义。然后基于分布式的场景,衍生了分区容错性以及可用性的概念。
CAP 定理作为后来者,为分布式系统而生,是分布式系统设计的指导方针。理解了 ACID,更有利于你去理解 CAP。
总结
工作中,我们参与开发的系统大部分都需要承载各自的业务,而这些业务就是在处理过去发生以及正在发生的事件。那么,如何确保这个过程中产生的数据能被准确无误地保存下来,是我们要格外重视的部分。因此,在运用数据库的时候,我们不单单要知其然,还要知其所以然。
文中我还提到了很现实的一点,整个系统层面的一致性无法单方面地依赖数据库来满足。因为什么样的结果被认为是一致的,从源头上还需要你通过代码来定义。既然如此,建议你尽量通过上层程序的 Coding 来做一致性相关的校验。这样的做法会更有利于在做了负载均衡的分布式系统中,具备更好的伸缩性。好的伸缩性意味着一个系统可以根据流量的大小灵活增加或减少节点。从这个角度来说,应用程序由于更多承担的是逻辑运算,不需要存储数据,相比数据库,更容易去“伸缩”。另外,减少对数据库一致性校验的依赖,也可以大大缓解数据库所在主机的 CPU 压力,使得运用单体数据库的瓶颈来得更晚。
你在工作或者学习中,是否有遇到过数据异常的场景呢?是由于什么原因导致的呢?欢迎在下方评论区留言。
延伸阅读:分布式系统系列文章
第一篇:《拨云见日看什么是分布式系统?》
评论