| 导语 宇宙一致的基础:原子操作
一致三定律
1.存储空间的原子读写操作
一个存储空间,写入a,一旦返回写入成功,存储空间中必然可以读出a.
如果存在两个并行写入操作,一个写入a,另一个写入b。读出的结果只可能是a,b两种。
没错,这就是一切的基础。所有的一致性都在这个基础之上构建出来的。 回想一下:CPU寄存器,写入一个值a,必然有两种结果,写入成功,写入失败。一旦成功,值必然为a。不会存在写入半个a但是还给你返回成功。
同样内存的操作,也有寄存器的特点,但是由于多核心CPU的存在,多个CPU可以同时操作一个寄存器,A CPU写入a, B CPU写入b。 但是,无论如何,结果内存里只会出现是a或者b两中结果。不会出现写入了一半的a,另一半是b。有这个功能就能实现单核CPU下的锁。
硬盘也有原子操作:读写一个扇区。 这就能实现日志文件系统和数据库事务。
2. 存储空间原子写入后能返回写入前的值(锁)
在1中写入后同时能返回写入前的值。
在多CPU多线程中,一个线程写入一个内存地址,另一个线程可能偷偷修改这个内存地址。如果另一个CPU在写入时能返回写入前的值,就可以知道自己是不是修改了其他人写入的值。 自旋锁就是在这个基础上实现的,从而把多CPU并行执行串行化。
为什么磁盘不需要提供写入并返回原始值呢?因为磁盘读写相当于是个单线程。 想想如果有多个磁头同时写一个扇区,势必造成混乱。然而可以从磁盘调度程序,或者操作系统级别把磁盘扇区的访问串行化。
3. 顺序幂等操作
按顺序执行多个操作,每个操作可以执行任意次,结果是一样的。
所谓幂等操作,就是这个操作无论进行多少次,都不会影响结果。 比如把a变量设置成100,无论进行多少次,a依然是100。相反,给a加100这个操作,执行次数越多a越大。会影响最终结果。
所谓顺序,是指两个幂等操作必须有序执行。比如 设置a=100, 设置a=200。是两个幂操作,如果按顺序执行,就能包证这两步操作可以看成一个幂等操作。但是如果反过来执行,结果a会变成100,而不是正确执行的结果200.
锁实现- 1或2
锁(mutex)
如果是单线程多路复用程序,比如一些单线程协程库,虽然可以同时启用多个协程,但是本质上同一时刻只有一个协程在运行。原因是其背后只有一个内核线程。这种情况下可以用一个变量int lock = 0表示锁来保护一个全局共享变量int g = 100; 有人以为单线程多协程不需要加锁。 其实是错误的,比如下图中,协程a在中途挂起了,挂起前已经读了g的值。挂起后,协程B悄悄修改了g的值,或者删除了g. 那么,协程A被唤醒后,再次操作g会出现不一致的总是。如果g还是个指针,C++中将可能让程序Core掉。
解决的办法1:就是加锁。
解决的办法2:不加锁,在每个可能的协程挂起操作后,都重新读取共享变量g.
情况1. 单线程协程锁
#加锁if lock == 0: #因为是单线程,这里不会有其他人修改这个变量。而且读写都是原子的。这就是定律1.
lock == 1else:
挂起自己到这个lock上。#同时上述操作不会挂起自己,所以这个if ese整体上是原子执行的。
#即使当前线程被操作系统切出,切入时还是运行当前协程。不会有另一个线程或者协程执行这断代码。#临界区操作
x = g#做一个可能挂起自己的操作#返回后
#如果不是用加锁操作,那就是重新读g了。因为g可能被他协程修改
x += 10
g = x#由于x=g后挂起了自己,所以不是原子操作了。
解锁:lock = 0
唤醒其他等这个lock的协程
情况2. 单CPU多线程锁
如果是多线程,if lock == 0 : lock == 1这两步中间可能被中断。 lock==0是一条指令,执行后,时间片到了,当前线程被持起,糟糕的是另一个线程刚好也执行if lock==0。此时两个线程将同时获得锁。 这是因为,在加锁的过程中,CPU被时钟信号打断了,进而可能发生线程切换。 只要不打断加锁过程就能解决问题。 解决方法是暂时关闭时钟中断。而关中断这个操作是特权指令,必须在内核态执行。这就是为什么加锁必须要陷入内核态才行。 试想,如果用户态可以关中断,一个进程关了中断,那么时钟信号会被屏蔽,内核再也没办法切换线程了。
内核态:
关中断#加锁if lock == 0: #因为是单线程,这里不会有其他人修改这个变量。而且读写都是原子的。这就是定律1.
lock == 1else:
挂起自己到这个lock上。
开中断#同时上述操作不会挂起自己,所以这个if ese整体上是原子执行的。#即使当前线程被操作系统切出,切入时还是运行当前协程。不会有另一个线程或者协程执行这断代码。#临界区操作
x = g #做一个可能挂起自己的操作#返回后#如果不是用加锁操作,那就是重新读g了。因为g可能被他协程修改
x += 10
g = x #由于x=g后挂起了自己,所以不是原子操作了。
解锁:lock = 0
唤醒其他等这个lock的协程
情况3:多CPU多线程锁
最最糟糕的是多核cpu,两个if lock == 0:lock==1在同时进行。也同时获得了锁。关中断已经无效,因为一个CPU没法关另一个CPU的中断。
从目前来看,只有定律1是没法解决这个问题的。因为读和写是两个操作,不是原子操作。所以需要定律2。
假设有个原子指令是lock=1,并且返回了写入Lock前的值。看起来就像用当前值1交换出了旧值。所以定义此原子操作:
y=swap(lock, 1); 这是个原子操作,返回lock写入1前的值。也就是说两个CPU,只有一个能执行。两个CPU会抢占内存总线。并且只有一个CPU能抢成功。原因是有内存总线仲裁器,同一时刻只允许一个CPU写内存的同一个地址单元,而另一个CPU会被停掉。停掉CPU其实很简单,只要关闭这个CPU的时钟即可。这个操作是由硬件实现。由此可见软件上的原子性最终是硬件实现的。
注意:目前们已经在内核态讨论问题。两个线程由两个CPU执行。
然而问题在于,两个CPU会先后执行y=swap(lock,1). 执行前lock=0, 执行后两个cpu,CPUA得到y=1(表未抢到锁),CPUB得到y=0(表示抢到锁)。 然而,放任两放任两个CPU同时执行下去还会有问题:CPUB执行比较快,已经释放了锁。 CPUA才开始判断if y != 0. 显然,这会导致挂起CPUA上的当前线程。可是锁已经释放了,谁来唤醒线程A呢?
以上问题的本地在于多个CPU并行执行了y=swap(lock,1), 和{if y != 0:持起自己}。 解决方法就是不允许两个CPU这么做。这就用到了另一种锁:自旋锁。自旋锁是个死循环,一直调用swap(spin,1). spin初始值0. 只有一个CPU能获得这个锁,另一个CPU将在这里忙等待。一直等到另一个CPU释放锁。 想想如果另一进程不释放,那么这个CPU将永远在这里死循环。
#在内核态执行,CPU关闭中断,不能再进行线程切换了。时钟中断也不响应了
加自旋锁:while True:
z = swap(spin,1)
if z == 0:
break
#加锁:
y=swap(lock, 1)if y != 0:
挂起自己
释放自旋锁:swap(spin, 0)
开中断
x = g#执行挂起自己的操作,比如网络io#一段时间后返回
x += 100
g = x
#解锁
swap(lock ,0)
综合上述三种情况:其中情况1和情况2由定律1即可保证。情况3由定律3即可保证。 同时,磁盘加锁,类比于内存里的锁,也能实现。当然锁的实现不只是这一种方式。1,2两条是实现锁的充分而不必要条件。也是在基于内存存储的比较好的实现。如果是磁盘,或者跨网络存储,就不会那么高效。关于如何实现锁,当然不止上文的还有现在比较火的CAS。前辈们发了很多论文:https://blog.csdn.net/luoyuyou/article/details/73780911
一切事务的基础- 1,3或者2,3
事务即若干操作,要么同时做好,要么同时不做。
如果是单CPU,那么只要满足1,3两条就能实现事务。包括分布式事务。 多线程加锁就相当于单线程。
如果是多CPU,那么只要满足2,3两条就能实现事务。 多CPU加锁就相当于单CPU,所以只要搞清楚单CPU的情况即可。
事务充分必要条件:原子操作,锁,幂等操作。
磁盘事务-日志文件系统commit/rollback WAL机制
磁盘由文件系统来管理。而操作系统崩溃,意外停电宕机,会导致文件系统损坏,例如:在目录x下新建一个文件y:
步骤1:从inode表里分配一个inode
步骤2:在目录x中添加一项,并记录文件名y.
很不幸的是,在分配完Inode之后,系统掉电了。步骤2没有完成。系统重启了,文件y有inode,但是没法访问,因为没有访问路径。如果查看x目录下的文件,也不会有y。
上述情况需要,步骤1,2要么同时完成,要么同时不做。
让我们再抽象一下:我们要同时修改几个扇区:A,B,C.... 要么这些扇区同时修改,要么这些扇区都不修改。这是一个事务。
我们简化考虑要同时把data1,data2写A,B两个扇区的第1个字节的情况。 解决方法就是记录日志。再开辟第三个扇区X来记日志。
下述操作可以保证事务一致性。
1. 读取扇区A,B,X到内存
X里添加:标志开始事务2. 内存操作:X中记录old_a1=A[1]
硬盘操作:写入X到磁盘3.修改A[1]=data1
写A到磁盘
4. 内存操作:X中记录old_b1=B[1]
写入X到磁盘5.修改B[1]=data2
写B到磁盘
6.清空日志X
下面分析如何回滚事物
以上每一步都可能失败,系统都可能宕机。
第1步宕机:没有影响,磁盘没被写过。
第2步宕机:重启后查看日志X:如果之前写入X到磁盘已经成功(可以根据X里的内容判断),则回滚A[1]=old_a1,并把A写入磁盘。此时磁盘没有变化。如果写入X到磁盘失败,什么也不做,磁盘数据A,B也没变。
第3步宕机:重启后查看日志X,继续第2步恢复A[1]=old_a1。
第4步宕机:重启后,重启后查看日志X:如果之前写入X到磁盘已经成功,则回滚B[1]=old_b1(B[1]写成功时)。再回滚A[1]=old_a1.
第5步宕机:重启后,和上一步一样操作。
第6步失败:重启后,也可以读取X中的日志,恢复A[1][B1]
由上面分析可以看出,总是可以保证A[1],B[1]同时修改,或者都没有修改。这是利用了磁盘写入一个扇区有原子性。否则,比如在第4步写入X时,把A[1]相关的数据丢失了。那么重启后就没法恢复A[1]。
然后上边的顺序不是唯一的:可以在第2步把日志写好,并记录开始提交事物。 如果第2步失败,则什么不用做,A,B,X都保持不变。如果第2步成功了,后边任意一步失败,重启后,可以根据日志继续执行3,4,5从而完成事务。如果只有5失败了,那么重启后重新执行,3,4,5. 3,4可能被执行多次。 这里就需要幂等性了。可以看到set A[1] = data1这种操作是幂等的。
同样,如果有多个事务都在执行对A[1]的写入,那么顺序就很重要,在对A[1]操作前就要加锁了。保证日志X中的顺序是正确的。
1. 读取扇区A,B,X到内存
2. X中记录old_=data1_old, set A[1] = data1,
X中记录old_=data2_old, set B[1] = data2,
X中记录,开始提交事务
写入X到磁盘
3.修改A[1]=data1
写A到磁盘
4.修改B[1]=data2
写B到磁盘
5.清空日志X
基于磁盘的原子读写操作是一个扇区,我们可以让整个文档写入变成原子操作。我们知道文件分配最小单位是一个扇区,也就是说,文件每512字节是一个原子读写单位。 当然我们平时调用write函数写文件,只是写到了内核的高速缓存中。这些缓存还是在内存中的,如果系统掉电,还是会丢失。像linux这样的操作系统提供了fsync()这样的系统调用,会保证数据写入磁盘才返回成功。
如果你想原子操作一个文件,比如写入200KB数据到文件f,要么写入成功,要么都没写入。可以开另一个文件X作为日志文件。
1.打开文件X,写入"TransStart", 写入200KB数据,调用fsync(X),成功返回0才继续下面操作。
2. 打开文件f,写入200KB数据,调用fsync(f),成功返回0.
3. 删除文件X(操作系统保证了这个操作的原子性。当然,也可以在X中写入TransStop.
可以见用fsync来记录日志就能实现另一个文件的事务读写。数据库就是这么实现的。
从上边可以看出为了保证x字节的数据一致,至少要另开避>=x字节。而且同样的数据被写了两遍。让我们想想,如果文件f中不记录x中的数据,那么重新启动后,只能删除f文件0开始的200KB数据。 没有办法恢复数据。
数据库事务,回滚事物 Undo/Redo
额外写日志是最基础的方法。数据库事务也用到。 比如要同时修改两张表:tableA的id=x这一行的value(初值200)减100.给tableB的Id=y这一行的value(初值20)加100. 那么在执行真正的表操作前,先打一个日志文件写日志如下:
1. 生成开始事物标志并sync写盘 2.set TableA id=x的value = 100.(这里用set是因为set是幂等操作). 3. set TableB id=y的value=120. 4. fsync日志落盘。 5. 生成开始提交事务标志. 并调用fsync等写入成功. 当然文件访问时间等数据可以暂时忽略。如果想保证的话利用扇区原子操作技术总能保证。 6.修改taableA的值,7.修改tableB的值. 8. 删除日志文件。
同样我们可以分析出,无论在哪一步宕机,都可以roback.如果第5步成功,则可以利用日志重新执行6,7,8. 如果第5步或者之前失败,那么数据库没有被改过,只要删除日志就可以了。 同时需要注意,fsync也可以是失败的,比如刚好写入数据是8字节,前4字节在扇区1,后4字节在扇区2. 结果写第二扇区的时候失败了。 解决方法有三种:1.开始,结束,提交这种标志性的数据只占一个字节。 这样这一字节就不会出现在两个扇区。 2. 数据要有结构 比如[len,flag, checksum}. Len表示后边数据的长度,flag就是开始/提交的标志。checksum是len+flag的一个hash校验。 这样就能保证len, flag,checksum作为一个整体。 不会出现checksum写入了,len还没写入。 其实只要{len,flag}就可以了。因为文件系统是个流操作,操作系统在写完数据后才更新文件的长度属性。
检查点
是一种定时备份机制,比如定时把系统状态备份一下,下次从这个状态开始。还以数据库为例,每次开始事务前,把当前数据库复制一下。然后写入数据, 最后删除备份的数据库。简单粗暴好用。如果有宕机,重新后,把备份恢复就行。
分布式事务
假设你要同时修改两个数据库,这两个数据库在两台机器上。通过网络操作时,会有网络中断,超时等可能性。 但是不要忘记了,每个数据库都可以执行事物,也就是可以进行原子操作。既然这样,那么N个数据库构成的系统,不就正和磁盘一样。想像每个数据库是磁盘的一人扇区(一个原子操作的单位),多个数据库就是多个扇区。多个扇区的原子操作能实现,多个数据库的原子操作也能实现。
假设你要的A数据库中的数据X减100,B数据库中的X加100. 一开始两个数据库中的X都是100. 正确的结果是要么不变,要么A中的X变为0,B中的X变为200.
数据库A开始事物:
数据库B开始事物
1.A.x=0
2. B.X=200
3. A.log=A的X由100变成0. B的X由100变成200. 开始提交。
4. A.Commit
5. B.Commit
6.删除A.log
显然前4步失败,AB都会回滚。 如果第4步成功,第5步失败。那么会出现A数据的事务已经提交,B的事务没提交的问题。 此时事务执行程序重启后,重新与数据库B建立连接。第一步要做的就是检查A中的log.发现了A.log.那么先执行A.log. 此时B中的X又被写为200.这时可以删除A.log了。 当然,在操作A,B两个数据库时,要加锁,防止B数据中的X后续被其他程序修改。
可以看出,第3步这种记录日志的方法,在第5步失败,在恢复时有两种选择,一种是回滚,一种是继续完成A,B. 因为有标志【开始提交】才能继续完成事务A,B. 还得注意的是,分布式事务必须有一个集中的管理者来执行事务。否则,A数据库中的x还可能被其他程序修改,那么在第4步成功,第5步失败的情况下,重启要恢复A的x时,X可能已经被改了,不是100了。
TCC事务
事务中的操作分为两中,一种是总是能成功的,比如给B帐号加100块钱。另一种是可能失败的,比如扣A帐户里100块,因为A帐户可能不够100块钱。 利用要分别从A,B,C三个帐户里扣100块钱,给D和E各加100块钱。 我们总是把一定成功的操作放在最后。当系统出现宕机或者断网等其他故障时,重启后通过日志来补救。 但是A,B,C三个帐户里扣100块钱,可能会失败怎么办?
假如我们有个事务程序:假设ABC原来各有150.可以先让A,B,C预留100块钱。 预留操作就是A自己扣自己100块钱,并在数据库里开个变量X+=100,表示当前预留。 A-=100, x=100是在一个数据库里执行的,所以必然是原子操作。
如下操作:
写日志1:
A预留100剩余50 B预留100剩余50 C预留100剩余50.
开始执行预留:
请A预留100
请B预留100
请C预留100
写日志2:日志就是下一下要做的事儿
删除A的预留
删除B的预留
删除C的预留
开始提交:
提交A(删除预留x)
提交B(删除预留x)
提交C(删除预留x)
删除日志。
分析:如果在执行预留前有失败,直接删除日志。
如果在执行预留后失败,那么根据日志1,如下恢复:设置A的本金150,并且删除预留。这是一个原子操作,因为在同一个数据库中。同样恢复B,C。最后删除日志。如果在恢复中出故障宕机,也没关系,可以再恢复一次。由于是幂等操作,不影响。
如果在开始提交前失败,那么执行上一步的回滚,把预留都滚回去。
如果在开始提交后出现意外失败,那么根据日志2,重做3删除预留,然后删除日志。
我们通过预留的方法,把一个可能失败操作,变成了一个不可能失败的操作。这也就是TCC原理。可以看到,到开始提交这步,后面的删除预留操作,必然能成功。而且是幂等操作。所以,后续操作可以由于一个进程定时扫描并放到消息队列里去消费。这就是基于消息队列的异步事务。
在分布式系统,可能同时存在多个事务,比如要扣A帐户100, 可能有两个事务都要做这个操作,那么如果A有200. 就会出两个X. 分别属于两个事务。只要给这些事务分配一个全局唯一ID就可以了。 同时需要一个第三方进程来执行事务,这个进程保证了事务的执行顺序。比如第一个事务操作A时,就不能让其他事务同时操作A. 如果通过加锁来保证,也可能出现死锁。
副本一致性
副本一致性是指多个数据副本时要保持一致。比如一份数据x存了三分ABC,那么如果出现故障,我们能恢复出x。与分布式事务不同的是,分布式事务每个事务的数据是不一样的。而副本是一样的。但是本质上是一样的。上文提到的分布式事务是不可靠的,比如有A和B写入两个数据库DA, DB。 但是如果数据库所在的硬盘损坏了,事务还是没完成。 所以最好保证数据库A是一个集群,比如有Da1,Da2,Da3, Db1, Db2, Db3。 然后做这个6个数据库的事务。 此时Da1,Da2,Da3数据据是一样的。当然为了提高性能,Raft算法保证多一半写成功就行。
分布式选主 Raft算法/Paxos算法
经常有这种需要,在一群机器中选一个作为主结点的需求。 由于机器故障,经常需要换其他结点作为主结点。比如要在一个副本集群的基础上选出一个Leader来。然而这已经不是存储一致的问题了。这就上升到共识问题。如果有部分结点还可能故意搞破坏,发送错误数据,这就是经典的拜占庭将军问题,我们后续再探讨。