• 进入"运维那点事"后,希望您第一件事就是阅读“关于”栏目,仔细阅读“关于Ctrl+c问题”,不希望误会!

深度解读原子写

MySQL 彭东稳 7年前 (2017-02-09) 23840次浏览 已收录 0个评论

为大家介绍一下所谓的原子写,Atomic Write。

一、从文件系统删除文件说起

文件删除操作过程比较复杂,如果简化的来讲,可以分为两步:

1. 删除该文件在文件记录表中的条目。

2. 将该文件之前所占据的空间对应的块在空间追踪bitmap中将对应的bit置0。

假设该文件的文件名非常短,尺寸也非常小,只有不到4KB,那么,上述这两个动作,就可以分别只对应一个4K的IO(如果文件系统格式化时选择4K的分块大小的话),第一个4K将更新后的记录表覆盖到硬盘对应的区域,第二个IO将更新后的bitmap的这4K部分覆盖下去。仅当这两个IO都结束时,该文件才会彻底被删除。

该是问“如果”和“为什么”的时候了。如果,文件系统将更新记录表这个IO发到了硬盘上并且成功写入,而更新bitmap的IO没有发出、或者发出了但是正在去往硬盘的路上的某处,此时系统突然断电,那会有什么结果?

放在早期的文件系统,再次重启系统之后,会进入FSCK(文件系统一致性检查及修复)阶段,也就是WinXP那个经典的蓝底黄滚动条界面。因为文件系统会维护一个dirty/clean位,在做任何变更操作之后,只要操作完成,该位就被置为clean,那么下次重启就不会进入FSCK过程,而我们上述的例子中,这两笔IO是一组不可分割的“事务(Transaction)”,一笔事务中的所有IO要么都被执行,要么干脆别被执行,结果就是这文件要么完全被删除,要么就不被删除还在那,大不了再删除一次。但是,如果一笔事务中的某个/些IO完成,另一些没完成,比如,记录表中已经看不到这个文件,但是空间占用追踪bitmap中却还记录着该文件之前被占用的空间的话,那么表象上就会看到这样的情况:双击我的电脑进去某个目录,看不到对应的文件,而右键点击硬盘属性,却发现该文件占用的空间并没有被清掉。这就产生了不一致。所以,FSCK此时需要介入,重新扫描全部的记录表,与bitmap中每个块占用与否重新匹配,最后便会将bitmap中应该被回收却没有来得及回收的bit重新回收回来。

所谓原子写,就是指一笔不可分隔开的事务中的所有写IO必须一起结束或者一起回退,就像原子作为化学变化中不可分割的最小单位一样。

二、单笔写IO会不会被原子写?

上面的场景指出,一笔事务中的多笔IO可能不会被原子写,那么单笔IO总能被原子写了吧?很不幸,也无法被原子写。原因和场景有下面三个:

2.1 上层一笔IO被分解成多笔IO

上层发出的一笔IO可能会被下层模块分解为多笔IO,这多笔IO执行之间如果断电,无法保证原子性。有多种情况可以导致一笔IO被分解,比如:

A.   IO size大于底层设备或者IO通道控制器可接受的最大IO size时,此时会由Device Driver将IO分解之后再发送给Host Driver。

B.   做了Raid,条带深度小于该IO的size,那么raid层会将该IO分解成多个IO。

2.2 外部IO控制器不会主动原子写

那么,当一笔IO(分解之后的或者未分解的,无所谓)请求到了底层,由Host Driver发送给外部IO控制器硬件的时候,外部IO控制器总可以实现原子写了吧?IO控制器硬件总不可能只把这笔IO的一部分发给硬盘执行吧?很不幸,IO控制器的确就是这样做的。比如,假设某笔写IO为32KB大小,IO控制器并不是从主存将这32KB数据都取到控制器内缓冲区才开始向后端硬盘发起IO,而是根据后端SAS链路控制器前端的buffer空闲情况,来决定从Host主存DMA多少数据进去,数据一旦进入该buffer,那么后端SAS链路控制器就会将其封装为SAS帧写到后端硬盘上。这个buffer一般只有几KB大小。所以很有可能一笔主机端的32KB的IO,在断电之前,有部分已经写入硬盘了,而剩余的部分则未被写入。虽然主机端的协议栈、应用都没有收到这笔IO的完成应答,但是硬盘上的数据已经被撕裂了,一半是旧的,一半是新的。

(Adaptec的Raid控制器一般会将整个IO取回到板载DDR RAM,然后将对应的RAM pages设为dirty,然后返回给host写应答(向competition queue中入队一个io完成描述结构体)。也就是说,Adaptec的Raid卡是可以保证单IO原子写的,但有个前提是Cache未满,当Cache满或者某种原因被disable比如电容故障等的时候,就无法实现原子写了。至于其他的卡是否保证,冬瓜哥并不清楚。)

2.3 硬盘也不会主动原子写

硬盘本身并不会原子写。硬盘接收到的数据也是一份一份的,每个SAS帧是1KB的Payload,SAS HBA会分多次将一笔IO发送给硬盘。至于硬盘是否会将这笔IO的所有数据都接收到才往盘片上写入,冬瓜哥不是硬盘厂商的研发所以并不知晓,但是冬瓜哥知道的是,不管硬盘是攒足了再写还是收到一个分片就写,其内部的磁头控制电路前端一定也是有一定buffer的,该buffer被充满就写一次。不管怎么样,当磁头在盘片上划过将数据写入盘片期间,突然断电之后,盘片上的数据几乎一定是一部分新一部分旧的,不一致,甚至一个扇区内部都有可能被撕裂。纵使Host端的确会认为该IO未完成,但是木已成半舟。

Every Enterprise SCSI drive provides 64k powerfail write-atomicity. We depend upon it and can silently corrupt data without it.

对于PCIE接口的固态盘,情形也是一样的。SSD从主存DMA时一般每次DMA 512Byte,也就是PCIE Payload的普遍尺寸。当攒足了一个Page的数据时,SSD就开始写入Flash了,而并不是等整个IO数据全部DMA过来才写入Flash。但是仅当整个IO都写入完毕之后,才会向host端competition queue写入io完成描述结构。如果是打开了write back模式的写缓存,那么仅当整个io数据全部DMA到写缓存中之后才会返回io完成描述ack,但是掉电之后,不管是完整取回的还是部分取回的,未完成的io会不会由固态盘固件继续完成,就取决于固件的实现了。

三、IO未完成,再来一遍不就行了么?

有人说了,既然Host端知道某笔IO未完成,那么重启之后,对应的应用完全可以再重新发送这笔IO吧,重新把之前写了一部分的数据全部再写一遍不就行了么?这个问题很复杂,要分很多场景。

比如,Host未宕机,而是存储系统突然宕机,或者突然承载存储IO的网线断掉。此时应用程序会收到IO错误,取决于应用程序如何处理,结果可能不同。比如应用程序层可能会保存有缓冲,在这里实现原子写,比如应用可以在GUI弹出一个重试按钮,当外部IO系统恢复之后,用户点击重试之后,应用会将该原子Transaction涉及的所有IO再次重新执行一遍,此时便可以覆盖之前不一致的数据为一致的。而如果外部存储系统长时间不能恢复,而应用程序也被重启或者强行关闭的话,那么该Transaction未完成,而且在硬盘上留下不一致的数据。当应用再次启动的时候,取决于应用处理方式的不同,结果也不同。

比如应用完全依靠其操作员来决定该如何处理,比如如果是数据库录入,录入员上一笔录入失败,那么其势必再次录入,此时应用可以将录入员再次录入的数据覆盖之前不一致的数据。但是更多实际场景未必如此,比如,录入员可能并不是根本不管其要录入的记录之前是什么而直接录入新数据,而是必须参考之前的数据来决定新数据,而之前的数据已经不完整,或者录入员并不知道该数据是错误的,而在错误数据的基础上计算出了更加错误的新数据,从而将更加错误的数据更新到硬盘上,埋了一颗雷,这就是所谓数据的”连环污染“。

再比如数据库类的程序,其虽然记录了redo log用于追踪所有的变更操作,但是一旦某个数据块发生不一致,redo log是无能为力的。如下图所示的场景:

深度解读原子写

可以看到,1时刻内存中的该数据块,其CRC与数据是匹配的,而掉电后硬盘上的状态,CRC与整个数据块是不匹配的。数据库之类对数据一致性要求非常高的程序都会对每个数据块做校验以防止数据位由于各种原因发生bit跃变。但是对于上图最右边的情况,数据库程序是无法判断该块到底是发生了bit翻转,还是由于底层没有原子写而导致的CRC不匹配,校验错误,所以会认为该块是一个坏块。当然,本例中,我们预先知道该坏块其实是由于原子写失败而导致的,但是程序并不知道。其实,此时用redo log强行把4321再覆盖到第一行上,就可以恢复数据,但是数据库并不敢去这么做,已经说了,数据库并不知道该块是不是由于比如第二行或者第三行里某些数据位发生反转而导致的CRC校验错误,所以不能直接把4321再写一遍到第一行上就认为该块被恢复了,为了验证该坏块是否是由于4321未被原子写入所导致,数据库可以先读入该块到内存,然后根据redo log把第一行改为4321,然后再算一遍CRC如果与坏块中的CRC一致了,证明该坏块的确是由于4321被撕裂而导致CRC不一致,此时数据库可以把CRC更正过来然后恢复该块。但是,如果是下面这种场景,数据库就无能为力了:

深度解读原子写

该例中,第一行被完整更新,但是CRC未被完整更新,导致撕裂。数据库发现redo log中的4321已经被完整更新到了数据块上,但是CRC依然错误,那么此时数据库无法判断到底是因为数据块中其他的数据位发生了翻转出错,还是因为CRC未被原子写,此时数据库无计可施,只能报告坏块。

四、业界为了避免数据不一致而做的妥协——两次/三次提交

4.1 文件系统日志

如果将要写入硬盘数据文件中的数据/元数据先不往原始文件中写,而是写到硬盘中的一个单独的文件中,这个文件被称为journal/日志(也有人叫log),FS收到下游协议栈返回成功信号之后才向应用返回写入成功信号。(这里又牵扯到如果设备写成功了,而FS在向app返回成功信号时断电,那么app认为没成功,而底层其实已经成功了,此时就需要靠app来决定下一步动作,比如重新再来一遍,或者后续发现其实已经成功了)。这个过程中,原始数据文件是没被写入的,依然保持上一个一致的状态。日志中的数据/元数据在某些时候会写入到硬盘上的原始数据/元数据文件中,比如每隔几秒钟会触发一次针对原始数据/元数据文件的写入,这个过程叫做checkpoint或者commit,如果这期间发生掉电宕机,重启之后FS可以分析日志,将日志中没有commit完成的操作再commit一遍到数据文件中。

有人会有疑问:如果FS在写日志的时候,发生了宕机掉电导致的数据块撕裂怎么办?那么发生撕裂的这笔IO就处于未完成状态,此时该IO的数据会被完全丢弃,以原始数据文件中对应的数据为准,也就是应用再发起针对该IO目标地址的读操作时,FS会从原始数据文件中读出内容,此时读出的便是上一次的一致状态的数据,此时应用可以基于这个数据继续工作,比如选择重新录入新数据。这就是两次提交的好处,第一次先提交到日志文件,一旦IO被撕裂,那么原始数据文件中的数据依然是完好的。那么,如果数据被commit到原始数据文件的过程中,如果一旦发生数据块撕裂,怎么办?这个可以参考上述那两个图中所示的场景,FS只要重放(replay、redo)日志中未完成的操作,重新覆盖一边对应的数据块即可,由于多数FS并不对数据块做校验,所以不会出现上述那个问题。

4.2 MySQL Double Write Buffer

为了解决带校验的数据块撕裂导致的坏块误判问题,MySQL采用了三次提交的方式。第一次先将IO写操作数据提交到redo log日志中(如果IO尚未写入log或者写了一部分尚未commit之前宕机,那么重启之后根据这个断点undo回上一个commit点时的数据),第二次将本应commit到原始数据文件中的数据再写入到硬盘上一个单独的文件中,叫做Double Write Buffer,当commit到DWB的期间一旦发生掉电宕机,那么DWB里的数据就是不一致的,那么重启之后,数据库可以利用redo log+原始数据文件(一定是一致的),来重放/redo,从而将系统恢复到最近的时刻,重放期间依然是先从log写入DWB,再从DWB写入到数据文件(因为如果绕过DWB,直接从redo重放到原始数据文件的话,一旦该过程再宕机,原始数据文件就可能不一致,最后的希望也就没了),如果重启之后又宕机了,就再来一遍,循环。数据文件在被提交到DWB之后,就相当于有了一份备份,数据库再从这个备份中将数据导入到原始数据文件,如果导入过程中出现宕机,没关系,重启后只需要再从DWB的断点重新再次覆盖一下原始文件即可。

ext4文件系统的日志方式中有一个是data=jurnal,其底层就是先将数据和元数据更新都写到日志中,然后再提交到原始数据文件中,这种机制相当于MySQL的DWB了。

4.3 某传统厂商文件系统的做法

该文件系统也采用日志技术,但是为了保证速度,采用带电池保护的RAM来承载日志。在向数据文件commit数据的时候,也采用类似MySQL DWB类似思想,但是形式却不同。其每次都会将更新的数据写入到硬盘上空闲的空间上,并且同时更改映射表指针的指向。每隔10秒钟,或者系统内其他一些功能所触发,该文件系统对数据文件批量提交更新的数据,只不过,该过程并不是把数据拷贝到原始数据文件覆盖,而是把元数据提交,由于元数据的更改每次也都是写入空闲空间,所以元数据的提交无非就是最终将根指针的指向做一次跳转而已。一旦在这个过程中任何一处发生宕机,那么重启之后其可以利用日志重放之前的变更。由于其对每个数据块也做了checksum校验,所以其也会存在块撕裂导致的坏块问题。与DWB机制一样,其每次将数据写入空闲空间,上一次commit之后的数据文件并没有被更改,所以一旦遇到坏块,那么其可以利用日志+上一次的原始数据文件来进行重放,如果在这个过程中又出现问题,或者各种bug或者未知原因导致的重放失败,那么至少上一次成功commit之后的数据是可用的,其可以回滚到上一个状态,虽然有丢数据,但是至少可以保证数据一致。

据不可靠消息,Oracle并没有像MySQL一样对数据块采用三次提交的办法,而是数据直接由内存持续的写入硬盘中的数据文件,此时,存在一定几率由底层无法原子写而导致的块撕裂而无法使用redo log恢复,从而出现各种级别的错误,严重者甚至整个库无法被拉起来。

五、如何在外部存储设备上实现原子写

如果能够在外部设备中保证IO的原子写,那么诸如MySQL的DWB就可以不要了,会节省开销提升IO性能。如果在硬盘中可以实现多IO为一组的原子写,那么存储系统控制器里为了保证一致性而做的复杂机制就可以被简化。单IO原子写的前提是,操作系统内核模块比如块层里的LVM、软Raid等,不能把单笔写IO分割成多笔,如果在这里分割了,外部设备的单IO原子写就失去了意义。然而,根本就无法保证软raid和LVM不分割,这完全取决于条带或块大小以及应用下发的IO大小。另外,Device Driver会向系统上报一个对应设备所能支持的最大IO size,如果应用的IO size大于这个size,Device Driver也会将其分割,所以,应用层必须预先得到这些参数,然后加以配合来实现单IO原子写。

5.1 SAS/SATA硬盘实现单IO原子写

HBA场景:HBA处不适合负责原子写,因为HBA控制器内部要尽量简单和高效。那么当HBA将数据源源不断的用SAS或者SATA链路一帧一帧的传送给硬盘时,硬盘仅当将一笔IO的所有数据都接收到其内部缓冲之后,才发起写盘操作。而仅仅如此的话也并不能保证原子写,硬盘必须在内部采取日志方式,将该IO先写入硬盘上保留的日志区,日志成功写入后,再向HBA控制器发送cmd complement帧,同时才能开始向数据区写入数据,同时HBA再向host端的competition queue入队io完成描述。一旦上述过程宕机,硬盘重启后可以用日志redo。如果IO之前尚未完整的写入日志,则硬盘实际数据区的内容也依然完好,硬盘只需要从日志中删除该笔IO的不完整数据即可,就当该IO没发生过。这就是实现原子写的代价,一定是降低了性能。有一个要注意的地方是,采用了日志方式之后,要保证IO的时序一致性,比如有一笔IO已经被成功commit到日志,那么后续如果收到针对有重叠的目标地址的读IO,硬盘要返回已经提交的数据而不是从盘片上读出数据返回,这会增加计算量。

Raid卡场景: Raid卡是个带内虚拟化设备,适合实现原子写,由于其具有天然的优势——有超级电容的保护。Raid卡只需要保证将一笔IO数据完全DMA到其内部的缓冲器中,并将对应的缓冲器page置为dirty,之后,便可以向host端完成队列入队该io已完成的描述了。这便实现了原子写。

5.2 PCIE接口的固态盘实现单IO原子写

如果打开了wb模式的写缓存,固态盘必须将一笔写IO的全部数据都收到自己的缓冲内部之后,然后即可向host端完成队列入队io完成应答。如果没有打开写缓存,那么仅当该笔IO全部内容都被commit到flash之后,固态盘固件才会向主机应答。一旦上述过程中发生宕机,那么固态盘此时有两种方式来保证IO的原子性。

方式1:redo模式。当打开wb模式缓存时,该IO完整数据如果已经进入了缓存并且应答,那么固态盘要保证该IO在掉电之后,依靠电容将其数据写入flash,也就是掉电后必须redo。如果没有电容或者电容容量太低,那么就得将该IO先写入flash的日志区之后,再向主机应答,重启后redo日志。

方式2:undo模式。如果不打开写缓存,固态盘控制器从host端主存源源不断的DMA数据到内部的容量非常有限的缓冲区,只要缓冲满一个页面,控制器就开始向后端flash写入。那么,根据上文所述,会产生不一致。但是,此时可以利用SSD内部机制的一个天然优势,那就是,Redirect on Write机制,提到这个机制,大家可以想到上文中介绍的那个每次都重定向写的文件系统的机制,也是RoW。上一次的旧数据并不被原地覆盖,所以可以完好的保留。所以,SSD可以不等整个IO的数据都到缓冲就可以先让数据写到Flash,写成功之后,再更新地址映射表,正如那个特殊文件系统的做法一样,一旦数据写入过程中发生宕机,那么下次重启,由于地址映射表尚未更新,所以该目标地址自然就会被指向之前的旧数据,达到了“要么全写入要么不写入”的原子写效果。这种方式可以认为是出错即undo的方式,与数据库里的undo机制不同,由于SSD内部天然的RoW,不需要像数据库一样采用CoW方式将旧数据拷贝出来形成回滚段。

5.3 实现多IO为一组的原子写

要实现多个IO要么一起全写入要么一起都不写入的效果,就必须增加对应的软件描述接口,以便让上游程序告诉下游部件“哪几个IO需要实现原子性”,必须这样,别无他法。

方式1:带外方式。比如增加一种通知机制,单独描述给下游部件,比如“事务开始,后续发送的32个IO为一组原子IO。带外方式的缺点在于,IO必须连续,期间不能被乱入其他非该事务的IO,而优点在于能够节省开销;

方式2:带内方式。带内方式则是将该信息直接嵌入到每个IO上,比如第一个IO中嵌入“事务开始,事务ID=1,共32个IO,此为第一个”,期间可以被乱入其他非事务性IO或者其他事务ID的IO。该事务的最后一个IO(第32个)中会携带“事务结束,ID=1,共32个IO,此为第32个”的信息。带内方式优点在于灵活,可以乱入其他事务或者非事务的IO,以供底层更充分的重排优化,缺点在于开销大,每笔IO都需要携带对应信息。

由于多IO原子写无法透明实现,需要修改应用、内核以及外部硬件固件,生态关系协调困难,认知度低,场景少,基本上可以靠数据库自身的日志机制解决,所以目前实际产品中只有极少数采用私有访问协议的产品实现了,而实现方式是上面的哪一种冬瓜哥也不清楚。

然而,理想虽好,由于实现方式复杂,所以一般产品都选择不支持原子写。即便支持,也基本上是上述的出错即undo的方式,这样能降低实现复杂度,同时不增加IO的时延,因为IO流程与非原子写状态下是一样的,采用wormhle数据传输方式,只是写成功后才更新映射表,写不成功没关系,回滚到旧数据块上。而缓存模式+redo模式下,会增加IO时延,因为必须等IO数据全部DMA到缓存中之后才对主机端应答,相当于stor-forwarding数据传输方式。

摘自冬瓜哥:【冬瓜哥论文】原子写,什么鬼?!


如果您觉得本站对你有帮助,那么可以支付宝扫码捐助以帮助本站更好地发展,在此谢过。
喜欢 (3)
[资助本站您就扫码 谢谢]
分享 (0)

您必须 登录 才能发表评论!