首页 > 代码库 > 大尺度原子性的实现
大尺度原子性的实现
写在前面的话
原子意为不可再分、组成万物的基本元素。“不可再分”,在现代科学面前,略显尴尬,但在表达其本意时,大家都能领会其神,这真是极好的。
计算机科学领域的原子性,引申为一系列的操作,要么全部成功,要么全部失败,从“全部失败”(一系列操作都没有开始执行)的初始状态到“全部成功”(所有操作全部执行完毕)的之间再无其他状态。
在基本的原子操作之上,构建更大尺度的原子操作,是本文要探讨的主题。
现实中的原子性
为了加深原子性的印象,试举几个栗子。
- 12306就做得挺好,一开始大家看到的都是“有座,等待开售”,等时间一到,钛合金F5如果还健在的话,在某个时间点后,你能清楚看到“无票”。在“有座,等待开售”到“无票”之间,再无其他。
- 还是12306,刚刚表扬了一下,为了避免它过度自我感觉良好,鞭策它更好的进步,举一个做得不够好的例子:比如我要买从深圳到新化的票,实际上我的需求是要从千里之外在除夕夜前回到家乡。细化成在12306的操作,就是如果深圳到新化有直达的坐票,那赶紧订了,如果没有坐票卧铺也可以考虑,卧铺坐票都没那无座也行,实在不行,先深圳到广州,然后广州到新化,或者深圳到广州,广州到长沙,长沙再到新化……然后问题来了,这和原子性有个鸡毛掸子的关系?在抢票的当口,如果我是分段买的,先买了广州到长沙,结果长沙到新化和深圳到广州再也没票了,中间这个票岂不可耻的浪费了,这时候就得蛋疼的去退票啦,还给实际要从广州到长沙的同学添了堵。如果有了原子买票操作,可以先告诉12306大爷:如果深圳到广州,广州到长沙以及长沙到新化三段都有票,请务必帮我买下了,否则一张都不要给我买。嗯,有点啰嗦了,凑合着看。
- 升级最新版QQ时,出现的提示“总进度:85% 请勿中止安装,否则QQ将无法正常启动”。心惊肉跳兼烧香拜佛总算安装完成了,生怕酿成新版没见成,旧版已不再的惨剧。
硬件层面的原子性
硬件提供的功能就是一堆原始的积木,软件只能基于这些原始积木,搭建组合出更多品类、更大规模的积木(软件实现更丰富的功能),或者直接从最开始就提供这搭建好的积木(硬件直接实现更丰富的功能)。
在绝大多数场景下软硬件都是可以互相替代、不分你我的。然而在最基础最底层的地方,必然是硬件在支撑,颇有点唯物主义的意味。
众所周知,基于冯氏理论的计算机都建立在离散的0/1之上,0/1的栖身之所,就是“位”(bit)。那么可以先假设在“位”上的写操作是最基本的原子操作了。
在此基础上如何构建更多位上写操作的原子性呢?
1Byte为8bit,1Byte的写如果在硬件层面以8bit并行去写,以现在的工艺水平,应该可以做到要么8bit全部写成功,要么8bit全部没有写成功,而不至于其中1bit写成功,而另外7bit写失败。
之所以加上“应该”,实在是我瞎猜,不服来咬我啊。如果硬件层面做不到比1bit更多,那么现代软件工程大厦的基础就不牢靠。
对应到具体的硬件介质:
- 磁盘:以扇区为单位提供原子读写。(欢迎纠错,纯属瞎猜,不影响我继续讨论本文:)
- 内存:以字节为最小粒度读写,当然硬件层面提供了更多字节的读写(例如OS提供的各种整数相关的原子操作)
软件层面的原子性
有了硬件层面对原子性的基础支持,是否就可以用软件的手段构建出任意规模的原子操作了呢?如果可以,硬件层面的支持至少是多少,1b?2b?还是1B,或1扇区(512B)?
我的答案是,至少1b。为什么是1b而不是2b?是离奇?是阴谋?还是巧合?抑或是小龙虾扯着鸡毛掸?
这要从软件如何实现的基本思想出发去分析,软件去实现的规模,必然跨越了多个硬件层面的原子操作,那么问题又来了:
- 多个操作意味着中间任意的执行点都可能被某种人类可以理解或不可以理解的原因所中断,而导致只完成部分操作的情况。
- 并且,如何保证中间不会乱入其他影响当前操作的操作呢?
从硬件层面去看,软件层面的原子性显然是个笑话。但是在软件层面,我们可以做一点手脚,使得观察这个状态的观察者看不到中间状态。因为观察这个结果是否原子性的过程仍然在软件可控的范围内(有点read repair的意思)。回到上面的问题:
- “中断”导致的部分成功,就是原子性的天敌,全部成功/全部失败之外出现了第三态——部分成功。解决这个问题的方案很简单,就是识别出这个第三态,在用户观察结果(read)的时候,将其恢复成正常的两态之一(repair),当然也可以在后台偷偷的就修复了。因此,最重要的是记录数据是不是处于中间态,故1b足已,实现见下一节的说明。
- 可以用“互斥”之类软/硬件原语(另一个有趣的话题)去实现操作串行化,确保相关的操作之间不会再有其他非预期的操作。(MySQL之类DBMS系统提供的事务在这方面走得更远,提供了不同的隔离级别,在性能与互相影响之间供人取舍。)
磁盘写操作的原子性实现
先从最简单的开始,只考虑单磁盘写操作的原子性。
假设磁盘提供了一个在1b上的写操作原子性,如何实现NB(N Bytes,任意多字节,如1扇区则为512Bytes)的写操作原子性呢?
2种状态,0:终态(非中间态),1:中间态。
首先,借助上帝之手,将磁盘的状态位(1b),初始化为终态0,数据位(N Bytes)初始化为初始数据。
然后,Write(data,N Bytes)的操作来了,要么全部成功要么全部失败,结果不出意料写完N/2 Bytes的时候断电了,怎么对被覆写的N/2和剩下没写成功的另外N/2交待呢?考虑到后果的严重性,显然不能直接就开写,不然一半新一半旧,简直吓尿了。
这时正确的做法是:
- 备份要覆写的N Bytes数据到一个临时区域,这个临时区域专门用于备份,这一步如果备份到一半失败了也无关紧要,因为这个数据还派不上任何用处。
- 一旦第一步完成(经过N次中断后,终于皇天不负苦心人),再将状态位设置为中间态,这一步一旦完成,临时区域的数据就不能随意修改啦~
- 任性的开始往数据位写数据吧,如果最终顺利写完了,再将状态位还原为终态;如果运气太差只写了一部分,等到下次满血复活时,根据中间状态位,将备份数据重写到数据位即可,这个过程也可以多次中断-重试,直到还原成功,再将状态位置为终态。当然这次写操作最终也就宣告失败了。
可以看出以上的关键点就是提前备份可能被覆写的数据,记录这个中间状态,利用幂等操作不断重试,以此来对抗各种天灾人祸,直到最终成功,或者选择放弃当前写操作,用备份好的数据还原被写坏的原数据。从宏观的角度去看,最终实现了写N Bytes的原子写操作。这里消耗了同有效数据一样的空间作为备份空间,这就是原子性的代价了。
异构介质的原子性实现
异构就是指操作1是在某磁盘写上数据,操作2是调用某个黑盒接口执行一个神秘写操作(如给月球钻个孔),...
这样一系列操作,要么全部成功,要么全部失败。貌似步子迈得有点大,扯到事务的蛋了。
这一批操作,得先有一个唯一的标识——事务ID,这个ID关联一个状态,状态有未开始、执行中、正常完成、回滚完成四种状态(为什么是4种?那不就要2b了吗?事实上,“未开始”、“正常完成”、“回滚完成”这3种状态,而单纯从实现原子性的角度而言,可以统一归为非中间态。)
步骤如下: 1. 提前保存操作过程中可能会修改的数据,用于回滚 2. 修改状态为“执行中” 3. 依次执行各操作 4. 如果最终成功状态修改为“已完成”;否则,用第1步保存的数据开始回滚,回滚完成后状态修改为“回滚完成”。
这里一个重要的前提就是每个操作都是幂等的,即执行一次和执行多次的效果等价。
1属于准备阶段,这一阶段如果失败可直接弃之。第3步如果某一个操作失败,实际上还是可以重试的。如果最终还是重试失败,那么就要走到第4步中的回滚分支。实现上,回滚也是可以重试的,并且回滚如果中断了,理论上还可继续重试第3步,这时记录已经完成或者回滚完成的操作也是必要的了。
这不和之前一样一样嘛,显然事情没有这么简单。这已经走到分布式事务的领域了,简单罗列一下关键点:
- 操作或者接口设计成幂等性很重要(当然,这样会发现不了某些编程错误,例如一个操作在复制粘贴的时候多粘贴了一次,对最终结果当然没有影响,但是重复执行导致的浪费是可耻的!)
- 如何保证并发的“事务”之间不会互相影响,中间写的其他事务可以读到吗?如果失败了占用的资源如何释放?分布式锁、隔离性等奇怪的东西就出来了,吓得我都躲起来了。
最后,回到事务,即ACID,简单总结如下:
- A:原子性,本文的主题
- C:一致性,此一致性和CAP的C还不太一样,更多的强调关系数据库相关约束的完整性,比如定义了唯一键、外键等约束,在操作前后,这个约束要名符其实。不要再问了,CAP是帽子的意思。
- I:隔离性,并发事务的中间态如何互相影响的问题。
- D:持久性,数据写入永久存储介质。
大尺度原子性的实现