对强一致性模型的思考

最近在完善强一致性的存储系统的时候,发现对强一致性的理解不够透彻,步入了误区

强一致性存储系统的简单理解和定义

强一致性的理解:

resp返回给客户端之前,保证所有节点的内容都一致。

只要保证存储系统,在同步追加过程中强一致性,那么就能保证各个节点也为强一致性

至于是P2P系统还是主从系统,是如何的回滚机制,都不是强一致性考虑的内容。

存储系统的理解:

简单的分为KV存储系统和对象存储系统

1 最常见的存储系统key-value,代表有redis,mysql,mongodb等等

这些系统都是弱一致性模型的,对于并发写入,可以通过锁的机制(redis本身是单线程的,不存在这个问题),并发写在单线程的记录中,而后慢慢同步到从,就能保证主从的写入顺序是一致的

如果是强一致性要怎么做呢。例如对x进行多次写操作,必须得等同步机制完成,才能再写入,这个过程相当于存在写锁,而且这个锁耗时还较长。

如果是同时写x,y,那倒是没有问题。因此,强一致性的kv系统很少见。

2 对于对象存储系统来说,写入后再返回一个UUID,就能保证能高并发的写入

当然如果有对返回的UUID做追加写入操作,那还是存在阻塞写入的问题

我所做的系统,就是一个cache的对象存储系统,每次写入都是一次性的,写入后返回UUID。

显然不能每次写入都生成一个文件。

因此每次写入一个block,每个block在写入时是独占的,当所有空闲block的剩余容量都不够时。会新申请block进行写入。

由上层系统,将多个返回的UUID,offset,len在数据库中拼接成外部访问的同一个文件。

通过这种方式,来选择强一致性,不会影响吞吐量,仅仅影响响应时间。

基于主从系统的强一致性对象存储的理解

从主要是保证冗余系数以及可用性而存在的(主挂了以后从切换成主)

对于一个强一致性系统来说,如果一旦有节点挂掉,不管主从,都不再能写入,这样无疑是一致性最强的。

一 节点挂掉依然写入

如果有需求,需要在节点挂掉以后依然能够写入,如果没有机制约束,就会演变成弱一致性模型。

主要问题在于,新起的从如果没能达到一致,就切换成了主,同步会造成灾难性后果

观察一个具体的例子,针对一个单独的block的写入记录,括号内是已经提交的数据内容

1
2
3
主          commit_offset:5     data:abcde
从 commit_offset:5 data:abcde
故障从 commit_offset:3 data:abc

这里整个系统已经提交到5了,故障从还是3,然后主挂了,故障从成为主

1
2
3
故障旧主    commit_offset:5     data:abcfg
从 commit_offset:5 data:abcfg
主 commit_offset:5 data:abcfg

故障从,新的提交fg覆盖了原来的提交

如上面的例子,故障从切换为主后,还未达成一致就切换成主,导致同步的内容覆盖了原有内容

解决方法是为故障重启的从添加一个recovery模式,该模式下的从不能成为主,直到和主最新的一条commit内容达成一致。

二 节点故障就不能写入的其他解决办法

从上述讨论可以看到,当节点故障还能写入,会使得解决办法的复杂度增加

我认为从工程的角度来看,最简单的方式是

强一致性系统,节点故障就进入只读模式

提供多组,每组主从的方式,随机选择每组进行写入,当写入失败,就换组再写,写完记录下组的ID

这种方式,从工程角度而言,简单的多

三 节点挂掉后不再写入的其他问题

问题是,不管是怎么样的提交回滚模式,在数据未达成一致前,有节点挂掉,有可能会导致脏数据

1
2
3
主          commit_offset:5     data:abcde
从 commit_offset:5 data:abcde
从 commit_offset:3 data:abcde

主正在向第三个节点提交偏移量3,此时主挂了,整个系统的提交到3

1
2
3
从          commit_offset:5     data:abcde
主 commit_offset:5 data:abcde
从 commit_offset:5 data:abcde

此时第三个节点其实才是正确的

对强一致性模型来说,数据最少的节点才是正确的

但是新起的主有脏数据de,使得两个从都被污染

这种脏数据的本质原因,还是因为新起的主,commit_offset大于real_commit_offset,这是无法避免的

下面具体看两种不同同步步骤的例子

强一致性主从模型的例子

交互较多的同步步骤

现在有个系统M,S1,S2,写入数据def

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
1 请求到主,req 数据内容为def->M,M根据commited位置写入数据
M: abcdef committed:3
S1: abc committed:3
S2: abc committed:3

2 主发给第一个从,并且收到回复 M<->S1,S1写入内容
M: abcdef committed:3
S1: abcdef committed:3
S2: abc committed:3

3 主发给第二个从,M->S2,S2写入内容
M: abcdef committed:3
S1: abcdef committed:3
S2: abcdef committed:3

4 主收到第二个从的回复(此时所有从都已经回复),M<-S2,对自己进行commit
M: abcdef committed:6
S1: abcdef committed:3
S2: abcdef committed:3

5 主发给第一个从commit,M->S1
M: abcdef committed:6
S1: abcdef committed:6
S2: abcdef committed:3

6 主收到第一个从的回复

7 主发给第二个从commit,M->S2
M: abcdef committed:6
S1: abcdef committed:6
S2: abcdef committed:6

8 主收到第二个从的回复

9 主给客户端回复,resp 4<-M

同步失败:

对主来说,任何一个向从发送请求失败,都应该立刻回复客户端操作失败,并且立刻将主设为只读

故障恢复:

1 从挂了又重启,直接将主设为可写,以commit为基准继续写入数据即可

4阶段以后的错误,都有可能造成脏数据

2 主挂了,重新选出的主,就有可能会遇到脏数据的问题

依然以commit为基准继续写入数据即可,这样脏数据相当于是无法被删除的数据

由于故障恢复的次数毕竟较少,因此从工程角度而言,是可以接受的

简化的同步步骤

现在有个系统M,S1,S2,写入数据def

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
1 请求到主,req 数据内容为def->M,M根据commited位置写入数据
M: abcdef committed:3
S1: abc committed:3
S2: abc committed:3

2 主发给第一个从,并且收到回复 M<->S1,S1写入内容,**并且更新commited字段**
M: abcdef committed:3
S1: abcdef committed:6
S2: abc committed:3

3 主发给第二个从,M->S2,S2写入内容,**并且更新commited字段**
M: abcdef committed:3
S1: abcdef committed:6
S2: abcdef committed:6

4 主收到第二个从的回复(此时所有从都已经回复),M<-S2,对自己进行commit
M: abcdef committed:6
S1: abcdef committed:6
S2: abcdef committed:6

5 主给客户端回复,resp 4<-M

同步失败:

对主来说,任何一个向从发送请求失败,都应该立刻回复客户端操作失败,并且立刻将主设为只读

故障恢复:

1 从挂了又重启,直接将主设为可写,以commit为基准继续写入数据即可

4-5之间出错,就可能造成脏数据

2 主挂了,重新选出的主,就有可能会遇到脏数据的问题

依然以commit为基准继续写入数据即可,这样脏数据相当于是无法被删除的数据

由于故障恢复的次数毕竟较少,因此从工程角度而言,是可以接受的


总结

1 从工程角度而言,强一致性模型,有节点挂掉,主就为只读,是简单有效的

能保证客户端认为写成功的数据不会丢失

2 强一致性模型,当出现故障以后,脏数据是无法简单避免的

客户端认为不会写成功的数据很有可能保存下来,而这从工程角度而言其实无伤大雅,对于一般需求的场景,没有必要做机制来处理