数据同步对于大家已不再陌生,数据同步基本上分为两类,一类数据库之间同步,一类是编写代码实现系统之间同步,本文是野狗架构师谢乔在2015全球架构师峰会上基于数据同步云服务架构实践的演讲,系统之间数据同步,主要分为三个方面:野狗的数据同步理念,数据同步的架构演进,数据同步的细节问题。
以下为演讲实录:
可能大家在实际的应用场景中不使用数据同步的业务模式,但是我是想跟大家分享我们在演进过程中一些问题的解决思路,希望能对大家有所帮助。
今天的演讲内容主要分三个议题:
首先从云端这块儿开始讲起,我们的数据存储是个Schema-free的形式,树形的数据库像一颗Json树,更像前端工程师们用的数据结构,它能把原来的关系型数据通过一些关联查询形成聚合型的数据,比如blog,里面有标题、回复等内容,就相当于把数据重新聚合,这样数据之间的关系就更直观了,方便大家快速的设计比较好的数据结构,完美的与url结合,每条数据都通过url来唯一定位,每个path作为一个key,就成为了key-value的数据结构。
经典的云服务是这样的:现提供一个API,然后有其他的auth接入,云端有存储,有用户管理,有hosting功能,还有周边的一些工具,客户端通过rest api这种方式与云端进行交互来开发你的业务模型。
而野狗除了这一部分以外,还有一个富客户端的SDK,本地也做了存储,当本地数据发生变化的时候会通过一个事件来通知用户,然后用户进行修改。
具体来讲,是客户端与服务端建立一个长连接,来完成数据同步,当同步完成之后产生数据变化,就可以完成业务逻辑的实现。如果我们把模型再抽象一点,就像一个主从的同步,客户端作为从,和云端进行副本级的同步过程。
也可以有另外一种同步方式,大家的服务可以与野狗云进行实时同步。比如说,你的服务端进行了一次数据修改,同步到云端,云端把这个修改同步给关注这个数据的客户端。
数据实现同步的基本模型是这样的:
开始有一个初始化的慢同步,可以做全量的同步或者条件同步,比如这个例子,客户端A进行了条件同步,同步到本地产生了一个本地副本,客户端B通过全同步拉取到本地形成一个本地副本。当客户端A修改后,产生了新的数据,我们把它叫增量同步,数据会push到云端。然后本地使用best-effort模式,客户端先成功触发事件,然后再同步到云端,云端再同步到其他的客户端,实现最终一致性。
这个过程很像op log的过程,也是基于长连接的,如果每次连接发生了异常,这里会重新连接进行一次初始化慢同步过程。这也是我们所做的数据同步和消息推送的根本区别,原因是,消息推送要保证每个消息顺序到达,而且不丢失,数据同步则是在性能上的提升,只关心最终的数据状态。一旦发生异常,客户端重新连入到云端以后,不会把之前过程中的op log都传过去,只需要重新进行一次初始化操作,让两端进行同步恢复就可以了。
刚才讲的业务方面的内容可能比较枯燥,接下来就是我们技术架构的演进过程。
首先看一下我们技术架构的特点,跟其他传统业务不太一样,属于写多读少。因为读只需要读一次到客户端以后,读客户端的副本就可以了,而且一些修改操作直接修改客户端本地,再由终端同步到云端,剩下的操作大部分都是写操作。写同步当然是越实时越好,但问题就是读的性能肯定会有一些延迟,后面会详细讲解。
我们实现的是最终一致性,因为这不是强一致性的架构,很多客户端可以关注同一个数据节点的变化。因为我们采用最终一致性,所以会导致多个客户端可以同时进行写操作,就必然会产生写冲突的问题,所以并行写冲突的问题也要解决。
实时性是我们的特点,这里暂时不详细说。
最后一个是幂等操作。
这是0.1版本的架构框图,这个主要面向我们的初期用户,用来验证我们产品是否被用户认可。这个架构由一个接入层组成,用来维护和客户端的长连接,如果有一个请求过来,会产生数据操作到数据处理,数据处理直接写Mysql。
Mysql这块儿直接用了主从同步的模式来保留一定的可用性,然后再进行数据推送。数据推送的时候,先从Redis集群中进行lookup操作,这个操作的目的是寻找要修改的数据节点被哪些终端所关注,然后再进行push操作。
这里的数据采用了物化路径存储,也就是说,如果存的是/a/b/c的数据,实际上是存/a一条/a/b一条,/a/b/c一条。
业务得到认可之后,需要对早期用户有一个性能的保证,所以就有了这个0.2版本的架构框图,把之前的Mysql改成了mongodb。使用mongodb的原因是可以动态创建数据库,把用户的数据在APP级别进行隔离,这样不会互相影响。同时,mongodb也带来了读写性能的提升。
同时我们采用了副本集多活,利用mongodb自己的副本集主挂了之后自动切从的方案。
机枪换导弹的意思是之前是一次一次对数据库进行操作,现在我们做了批量的操作和合并的push。之前的操作一个push会影响多个数据节点发生变化,会一条一条的推给关注的终端,现在可以做一个合并的push。
当我们的产品进入bate版测试之后就需要面向广大的公测用户了,我们逐渐要面对的就是写压力了。因为mongodb的写操作对于同一个数据表是锁表的,所以写是一个串行的性能问题,所以我们这里加了一个写缓冲队列,这是大家都会想到的解决方案。
我们这里使用了kafka。一条数据来了之后,由生产者进入kafka,然后由消费者把kafka的数据拿出来进行批量消费,最后内存生成一个操作树的缓存,再批量写入mongodb。这块儿更类似Nagle算法,达到一定的操作量或者达到一定的超时时间后,就同步到Mysql数据库。
可能大家有过加写缓冲的经验,这时候肯定会面临读性能下降的问题。因为这时候我们在读到mongodb的时候是一个已经过时的数据快照,有一些操作还暂存在kafka,写缓存队列中,所以必须要解决这个读不一致的问题。当读操作来的时候,先从mongodb中读取到快照,然后再记录你当前执行到哪,一共有哪些操作还未执行。读取完之后,在内存进行一个回放操作,拿到的就是比较新的快照版本了。
但是这里还有一个问题,在操作的过程中,还会有新的写操作过的内容,就算回放完,也是过期的版本。这里有点像redis的主从同步一样,拿到内存的最后版本后还有新过来的写操作进入push和wait队列,先把历史版本推给客户端,再把之后的写操作一次推给客户端。最后在客户端进行计算达到的就是最终一致性,用户拿到的就是最新的数据版本。
在beta版发布一段时间之后,服务器的负载是很平稳的上升,延迟是10、11、12ms,每周是这样一种递增。但是突然有一天我们发现延迟暴增到上百ms,甚至到700ms,我们开始各种排查。但是查过之后,kafka、mongodb等等,都一切正常,最后才查到原来是因为push这里需要查一次redis造成的。也就是说,我们在redis中存的是路径Key,路径下面是有哪些客户端节点关注了这个key,所以这里要进行一次模糊匹配查询,当一个实例的redis数据量到达20w、30w条的时候,如果用模糊查询性能会非常低,延迟会达到几百ms。所以我们这里采用了临时方案,用mongodb来代替redis,用mongodb加它的索引来提升模糊查询的性能。
这里也为我们敲了个警钟,我们需要做性能监控,才能真正的面对用户。后来我们就基于flume做了一套自己的性能监控。Flume可以统计日志,还有对每一个系统延迟的调用,以及异常报警,都写入flume,再做一个flume的后台处理。
我们在设计架构的时候,总是把我们的关注点放在最容易发生问题的位置,而往往有时候虽然你解决了这块儿的问题,但是由于总量上来了,还会影响一些原来不关注的地方出现问题,完全出乎意料。
刚才是简单架构框图的介绍,现在是我们数据同步面临的一些细节的介绍。
两个客户端同时修改本地的副本,需要考虑到数据的静态一致性,同时还要考虑到写隔离的问题。对于这个问题其实有两个解决方案:一是中心化锁机制;另外一个是进程间协商机制。但是锁机制会有单点故障问题。所以我们做了一个分布式树形锁机制。不过这里有一些需要注意的问题:1、tryLock和release 需要2次的交互;2、需要注意注册Lock的有效期;3、要等待Lock超时;4、最好使用动态hash;5、连接异常时退化。
还有一些性能问题,因为每个App都有一个树形锁,所以是单进程就算你进行了这种操作,在理论上是会有一个吞吐量的上限的。任何操作都要先去尝试先获得锁,这个操作其实是一个浪费的操作。主要性能的点有两个:一个是单次push sync量比较大,可以导致阻塞。另外一个就是异步push sync。
因为以上这些原因,一个恶心的架构就诞生了。主要因为缩减了write操作的过程,还有要保证云端与客户端的一致性。整个系统就会太过于复杂,不确定因素太多。
但是我们做技术不能意淫。在真实的应用场景中,有同一客户端场景和不同客户端场景。但是两者所占的比例是不一样的。不同客户端的写冲突有0.3%,同一客户端写冲突有4.1%。所以说,其实冲突的概率是非常小的。用上面那种方式就会有种“杀鸡焉用宰牛刀”的感觉。
所以,我们提出了一个理念:让上帝的归上帝,野狗的归野狗。具体到实施上就是让用户进行可配置化,主要有四种方式:1、默认不启用;2、减少不必要的开销;3、降低锁粒度;4、由appld hash改进为path hash。在这里技术的同学就要注意了,有些问题其实不需要多么厉害的架构,如果能在业务层面进行解决,就尽量将问题在业务层面解决,不要做特别复杂的架构去解决一些虚无缥缈的问题。
要解决这些问题,主要还是依赖写时的树形锁,达到顺序push的效果。如果没有这个操作,就会出现客户端数据不一致的问题,所以push顺序很重要,一定要一致。
主要是需要保证同一客户端的顺序性。以“太空站”这个游戏为例。飞机走着走着回发生回退的现象,造成这个现象的原因,是因为客户端在进行写处理的时候是进行并行处理的。这个问题很好解决,可以按照客户端ID散列到每一个数据处理的进程上,在数据处理进程内部达到一个数据写一致的效果。进程内的锁也要实现顺序性,所以目标又变成了解决write的性能。
第四个问题就是最终一致性的问题,刚才我们说的都是云端和被同步客户端之间的问题。
但是这块儿还会产生的问题模型是客户端A在本地先做修改,由1修改成2,将2同步到云端以后,云端也修改成2,云端再push到其他的客户端,对这个数据有关注的,也会修改成2,这样就解决了最终一致性的问题。
看似很完美,但还是有漏洞。
刚才所做的这一切,只能保证云端和被同步的客户端的数据是一致的,但是这种情况由于客户端可以都先对本地进行修改,客户端A修改成2,客户端B修改成3,在推送到云端的过程中,A进行的修改会写入,B进行的修改也会写入。最后执行的时候如果在云端执行的时候是以某种顺序推送过来的,假设云端最后生成的是2那就是说,云端和左侧是一致的,就会与另一侧的节点产生不一致。
也就是说,由于并行写,最后会有一个客户端产生不一致的问题。
这里我们也没有用到一些复杂的算法,用了一个push给自己的模型来化解这个问题,达到最终的一致性。在并行写和推送的时候仍然推送给自己,由于推送的过程是串行的,只有推送完前面的一次,才会推送对这个节点的下一次改变操作。这个推送完毕以后,因为是TCP的,所以会按顺序推送过去,那就可以认为,在这个推送过程中,所有终端都达到了一致性。
会产生的问题大家也可以看到就是可能会出现,数据由2修改成3,再修改成2。在这里我们需要对一致性问题和性能做一个取舍,当然还是选择为了达到实时,所以采用这种比较弱的最终一致性方案。
最后一个问题,是一个原子性问题,因为我们是幂等操作,所以不会支持if then,i ++的操作。我们在这里用了一个自旋锁的CAS机制,在本地拉到数据之后做一个hash,这个hash和要修改的值做一个复合操作一起发到云端,而云端也对这个数据进行一个hash,如果两个hash是一致的,那才能认为可以操作,才能覆盖。如果不一致的话,重新从云端再次同步一些数据到本地产生一些副本,进行上一步的操作,直到成功为止。不过我们也有一个重试次数,现在的设置是20次。
今天的演讲就到这里了,谢谢大家。
-END-
本站内容采用 知识共享署名 4.0 国际许可协议 进行许可。