在分布式的情况下,出于可用性(单点问题导致全部不可用)和规模性(单点支撑能力有限)的考虑,通过使用多个参与者提供服务。
如何保证通过多个参与者写入和读取的值相同,即分布式中的数据一致性,是一个复杂的问题。
为了保持一致性,一般是两种方案,一种是所有节点每个事务里都一致(强一致);另一种不是所有节点在每个事物里都完全一致,比如采用主节点先更新,其他节点追加式更新的模式。
强一致模式下,服务可能会因为等待所有节点的同步更新和反馈,服务变得非常慢或者中断。节点间不完全一致的模式,如果仅看一个节点,可能在主节点崩溃后,其他节点没有最新数据。
受分布式系统不可靠特点的影响,多个参与者在一个事物里不一致将是常态,因此使用强一致模式会容易出现不能达到或代价太高的问题。
两阶段和三阶段提交的本质都是想要达到强一致,即通过先做准备工作,参与者在准备工作做完后进入准备状态的一致,然后再一起做提交操作,达到参与者都一致的目的。但很明显,提交操作过程中还可能有新的不一致情况,所以不能保证达到强一致效果。
另外一条道路就是节点状态不一定完全一致,但是还可以达到写入和读取数据的值相同的效果。
从直观感觉上想到的是,要么取一个维度的最值情况来确定一个值,比如取一个最新时间等;要么取大家投票的方式,取多数的原则来确定一个值。
按照一个维度取最值的方法,困难在于不稳定性和误差。取多个节点(提供服务的参与者)中最新时间更新的值,取数指令发出后,可能记录最新时间更新值的节点宕机或网络不可达,那么就会出现读写不一致的问题。写进去的最新值是100,但读出来可能是50。即使节点和网络都正常,也还会存在授时误差的问题,节点1比节点2绝对时间新,但节点2的误差原因造成节点2记录的是最新的,也会导致读写不一致的问题。
基于上述情况,只能考虑基于大家投票的方式,取投票占到一半以上的值作为最终值(用一半以上是为了保证唯一性)。paxos算法就是此类算法。
因为paxos是理论性的研究,具体的工程实践差别较大,下面从我想到的两个经典例子进行说明。在例子中,使用3个请求者,5个分布式服务提供者。
例如,5个服务提供者最后更新的值,分别是:1,2,3,2,2那么投票结果就是2为最终值。如果没有一半以上的要求,那么1,2,2,3,3,3的情况就会没法得到最终状态值了。
1. 更新操作
最简单的情况是<key, old_value>更新成<key, new_value>的过程。
3个请求者分别发送一个或多个请求,将一个key的value进行更新。因为没有时间戳,服务提供者只能按照到达的时间先后顺序来更新值。
这种情况没有严格的时间顺序,不同服务提供者的更新情况可能差距很多,然后给最后更新值对应的请求者发送更新成功,给其他请求者发送更新失败。当然从理论上讲也是可以的,但是想达到一半以上很困难。
为了提升效率,可以在请求中加入时间戳,也就是<key, new_value, timestamp>的格式,这样服务提供者在接收到后可以根据时间来判断是否要更新,如果更大的时间值(更大意味着更晚的时间)那么就更新值,否则就不更新值,5个服务者更容易达成一致。
这样还不够,因为可能请求者1读取现在key对应的值是3,想更新成5,但是它的请求达到服务提供者时,该key对应的值可能已经被更新成8了,这时再更新成5不见得是预期效果,所以最好还要加上原值,如果原值不匹配的话更新失败,这样确保可以达到预期的更新效果。也就是<key, old_value, new_value, timestamp>的格式。
在更新操作中,使用这样的请求格式,如果一半以上的服务提供者返回了更新成功的结果,那么更新就认为是成功了。如果有服务提供者出现故障,通过多数的数量优势,恢复最后的值。如果3个更新成功,2个更新不成功,然后有1个更新成功的节点宕掉,这样应该是依赖于宕掉节点是否在日志中记录了成功的状态。如果记录了,恢复后认为更新成功,否则认为更新失败(当然这是工程手段,可能根据场景不同而不同)。
2. 插入操作
插入操作跟更新操作的不同之处在于其key值是不确定的,因此多了一个先要生成唯一key值的过程。
从理论上讲,可以每个请求者随机生成一个key值,但在工程上,为了符合key值的规则和后续查询的效率,通常是先去取一个当前最大的key值,然后做+1操作。
从理论上讲,有了这个key值以后,就可以加上value值发起请求进行更新操作;服务提供者接收到请求后,如果key值已经被占用那么直接返回失败,否则就进行插入操作,如果一半以上服务提供者都返回了成功,那么插入就成功了。
但在工程上看,多个请求者高并发进行访问时,可能会出现拿着同样的key值进行更新的情况,这样服务提供者会跟第一种更新的情况一样,插入完成具有随机性,会因为多个插入的碰撞导致效率比较差。
因此还是需要加上时间这个附加因素,又变成了<key, value, time_stamp>的组成结构,当然也可以把time_stamp和key组合在一起,这样可以进一步减少key值相同的可能,并把time_stamp放在高位上,确保时间新旧的可以通过比较大小获得。
这样就可以使用<key, value, time_stamp>或<key+time_stamp, value>格式进行插入操作了,当请求者把该请求发送给所有的服务提供者时,服务提供者按照时间是否更大的原则进行插入操作,如果一半以上的服务提供者返回了成功,那么这个操作就完成了。当然也可能出现下面的情况:
1)可能会出现key值被占用的返回,因为可能多个请求者都读取了最大key值然后进行了+1操作。
为了减少这个环节的冲突,可以先进行一次预请求,请求者先拿着key值进行请求,如果服务提供者插入过该key值,那么就反馈一个已经占用,否则就反馈没有占用。当然服务提供者此时并不会停止接受新的请求,可能下一时间就有其他请求者使用了这个key值,所以key值反馈不一定是对的。这是从工程上减少概率而已。我感觉这种情况适用于要插入的value值特别长的情况,这样因为冲突而造成的网络和时间浪费较大,所以通过预请求尽量避免一下。
2)可能出现一半或以下服务提供者返回成功的情况。存在另外一个请求者也使用该key值,不同的服务请求者插入了不同的value值。
以上基本上把原理说明了一下,但在工程实践中实现可能差距很大,可以根据具体的场景具体设计。