本文来自OPPO互联网技术团队,转载请注明作者。同时,欢迎关注我们的公众号:OPPO_tech,与您分享OPPO最前沿的互联网技术和活动。1.前言在互联网后台服务常用的经典架构中,整个后台服务框架一般可以分为四层:接入层,一般用于请求安全验证,频流控制逻辑层,用于用户数据请求和return逻辑控制缓存层,为了加快访问性能,缓存用户数据,常见的redis,memcache等存储层,落地各种用户数据,如mysql,leveldb等。考虑这样一个关于multi的常见问题异地活跃,假设本地机房所有业务都宕机了(机房断电或者光纤断了),此时如何挽救?比如上海的登录服务宕机了,就不能阻止上海的用户登录,显然我们需要把后台的请求流量切换到另外一个机房。当然,4层架构的每一层在后台的切换场景是不一样的。缓存层的作用是加快访问性能,减轻存储访问压力。如果流量直接切换到其他机房,可能缓存命中率过低,炸毁底层存储层,严重影响机房的服务。如何解决这些问题是本文要讨论的话题。本文主要针对数据一致性和数据安全两大挑战,通过版本迭代来说明多机房多站点多活在缓存层会遇到的一些问题。一致性在多机房中,机房之间网络延迟的不确定性较大;每个机房的物理条件可能不同;多机房服务运营的状态也会有所不同。如何在各种异常和不确定的情况下保证数据的一致性是本文的重点。安全性Google在GFS论文的开头也陈述了类似的前提:“服务异常或硬件故障可能是正常的”。如何保证已经写入的数据在异常正常情况下能够恢复是本文的另一个重点。2、原版是基于本文缓存层的多活问题,而redis目前比较常用,所以这里以redis为例来说明缓存层的问题。断电时内存数据易失。我们很容易想到第一个版本:我们先将写入的数据(修改和删除统称为写入)写入磁盘,然后使用另一个进程读取磁盘数据,发送到其他机房,如如下图所示:这样,我们的第一个原始版本就做好了,要在生产环境中运行,还有几个问题需要解决。第一个问题是“同步进程”可能会重启(代码bug或其他原因),重启后“同步进程”进程从哪里开始重新同步?第二个问题是数据可能不完整。比如redis会有aofrewrite,删除原来的aof生成新的aof文件,旧的aof文件会被删除只保留新的aof,这样旧的aof的offset就在新的aof中了文件变得无效。3.Version1针对以上原版本问题,我们做了一些解决方案。ReliableDB我们引入一个DB,将每个同步进程的位置写到DB中。同步进程重启时,首先从DB中找到自己的offset,然后从offset后面开始同步数据。流水记录参考mysql的方法,所有写入的数据以流水日志的方式按时间顺序记录,不可修改,同步到其他机房前不可删除。经过以上修改,我们得到修改版一。将修改后的版本放到生产环境运行几天后,会发现一些问题:性能低下。同步过程每同步一条数据都要修改Mysql同步偏移量,导致同步过程性能低下。多个服务混合,每个服务都在写数据到磁盘。跨机房同步本身就比较慢,可能会导致单机磁盘空间被炸飞。磁盘炸毁后,数据无法再写入。数据环回给ServerA,数据被同步进程A同步到机房B后,写入数据流B,然后同步进程B再把这个流同步到机房A,这样就形成了循环,浪费了网络带宽和资源。如上图,A机房写入的数据同步到B机房,再流回A机房。4.版本24.1解决流水问题针对版本1的2个问题,我们做一些方法来解决它们。同步偏移量定期写入DB。每一跳的数据被同步进程同步后,offset不会马上写入mysql,而是每隔一段时间写入一次。很明显,性能问题解决了,但是也带来了问题。如果重启同步进程,很有可能得不到实时偏移量。这样,部分日志会被重复执行。幂等流水线重放部分日志而不影响数据的准确性。我们把所有的写操作都转换成内存镜像操作,比如字符串数据结构在内存中的cnt=100,那么inccnt的执行就可以转换成setinc101这样的操作。inccntsetinc101hash、set、zset等其他数据结构都可以转换成这种操作,但是对于list队列数据,没有办法支持类似的幂等操作,此时可以忽略。加入消息队列是因为单机磁盘有??限,容易被填满,磁盘损坏数据无法恢复。使用如kafka或RocketMQ,将多台机器作为集群进行数据冗余,保证容量空间和数据安全。将idcid添加到管道格式。如果同步过程能够识别同步数据源来自哪里,那么数据环回问题就可以解决。比如同步进程A发现从B同步过来的数据源是自己,那么A就可以过滤掉这条数据。.4.2版本2的架构经过以上版本的迭代,整个系统都运行在生产环境中,但往往过一段时间后,你很快就会发现出现了很多新的问题:A机房的问题和B机房的数据有很多不一致的地方。如果在多个机房写入同一个key,很容易出现数据不一致的情况。如上图所示,同时在A机房和B机房设置了cnt。由于机房之间的同步时间不确定,导致A机房和B机房的数据完全不同。如果没有后续的写作,这个问题基本是无可挽回的。更多的机房数量并不全是生意。只是两个机房互相同步而已。它可能需要三个或更多的计算机房。简单的相互发送和接收数据已经不行了,需要改变整体结构。5.版本3继续讨论并一一解决版本2的两个问题。5.1解决数据不一致上面第一个问题造成数据不一致的本质原因是数据写入缓存时没有统一的版本控制。如果我们能对数据做版本控制,就不能随意写了,就可以达到数据的最终一致性。我们很快想到了一个版本的方法,在多个机房使用时间戳进行数据版本控制。好像可以按照写的绝对时间顺序来解决。如下图所示,我们使用时间戳作为版本。时间戳越大,版本越大。优先级越高,优先级高的版本数据可以覆盖优先级低的版本数据。A机房写入时间戳为100,B机房时间戳为150,A机房到B机房的数据同步无法成功,因为B机房的数据优先级更高;而机房B同步到机房A的数据可以成功同步,从而实现数据的一致性。这里有一个问题。如果时间戳一致,如何解决?轻重缓急是一样的,两个人似乎会有不同的看法。这里,可以手动设置一个优先级,放到配置文件中。例如A机房的优先级高于B机房,如果版本优先级相同,则以A机房为准。这样,通过上面的简单版本,我们暂时认为数据不一致的问题已经解决了。5.2多机房支持上面的讨论在两个机房同步时比较简单,但是如果是三个以上机房就不行了。面对多个机房的数据同步,此时有两种选择。中心型:数据先同步到一个中心机房,再从中心机房同步到其他机房。Pairwise相互类型:机房之间的数据相互同步,任意两个机房建立数据通道。缺点中心型优点结构简单:所有机房数据只需要同步到中心机房即可解决数据一致性问题:如前所述,需要给配置文件设置一个优先机房。如果有中心机房这样的角色,那么都以中心机房为标准,顺便解决一下一致性问题。如果版本号相同,谁占上风?缺点中心机房流量大:因为所有流量都经过中心机房中转,所以流量会比较大Failover:当中心机房出现故障时,会关闭整体同步通道。二二互利分散:任意机房平等,去掉任意一个机房,不会影响其他机房的数据同步。可以照常同步,流量相对均等。缺点是过于复杂:从上面四个机房的同步可以看出,建立的通道数据过多,在架构层面也要考虑各个机房的异常情况,这样会导致大局。很复杂在实际中,如果同步机房的数量不超过3个,可以使用以上两种类型,需要解决每种类型面临的问题。这里我们选择中心式来做多个机房的数据同步,接下来要解决的就是中心式的问题。centraltype需要解决的几个问题1.流量大是因为我们的流量是写流量,也就是说读数据不是流水线的。对于缓存来说,写入量一般不会很大。流量比较大的场景可能是导入数据的瞬间。2、如果Failover中心机房出现故障,或者中心机房的网络成为孤岛,一定是1)不影响其他redis机房的正常读写。根据消息队列的订阅和写入,我们将同步进程拆分为两个不同的独立第三方进程,“订阅进程”和“同步扫描进程”。Redis本身还是不参与同步,这两个第三方进程做同步。同步扫描流程:只负责扫描本地服务器binlog,然后写入本地消息队列,然后返回订阅流程:中心机房必须订阅所有其他非中心机房的消息队列来总结数据;非中心机房只需要订阅中心机房的消息队列即可获取全量数据。2)快速建立新的中心机房。当中心机房可能成为孤岛,无法与其他机房互通时,运维可以在DB中设置一个新的中心机房idcid。如果订阅进程和扫描进程发现DB发生变化,就会立即将新中心机房的idcid同步给所有同步组件。5.3版本3的架构解决了以上两个问题后,我们的版本架构以三个机房为例6.版本4版本3貌似解决了一些问题,但是在生产环境运行一段时间后,就可以了发现数据不一致的key还是会越来越多,甚至有很大的差异。看下图:B机房,执行“setcnt200”,时间戳为100,然后同步到A机房。A机房,执行“setcnt300”,A机房的时间戳可能是异常58,因为是业务的正常写入,假设不能先拒绝,那么机房A的内存状态为cnt=300,时间为58,当这个状态同步到机房B时,会失败,因为A机房的时间58小于B机房的100,此时会出现A机房和B机房存在相同key但不同value的情况。6.1继续解析数据inconsistencies如上图所示,由于A机的时间戳比较小,A机在该时间之后写入的数据无法同步到B机,导致A机房和B机房的数据不一致。这时候有两种选择:1、在一定条件下,正常写入业务的内存中的数据都有一个版本号。如果此时获取的版本号小于内存中的版本号,则拒绝该业务的正常写入。比如上图中的场景:A机房的时间戳比较小,如果此时小于cnt的版本号,则拒绝写入。这种方法是一种很简单的解决方案,但是如果某台机器的时间比较大,其他机器就写不出来数据,显然不可取。2.把这个繁重的任务交给运维或者时刻监控每台机器的时间戳。如有不一致,会报警并自动修复。这个时候,你的手机可能一直在闹铃。会不会有别的问题?因为多个机房之间的网络通信耗时较长,等待服务获取本机自身时间戳的时间也比较长,要保持多个机房的时间戳一致是不现实的。折腾了无数次,最后只能面对现实:不可能保证所有机房的时间戳都一致。6.2分布式逻辑时钟回到问题本身,我们使用时间戳作为key版本号来保证数据的一致性,假设即使时间戳不一致也能达到数据的最终一致性,那就不用纠结了”确保所有机器的时间戳一致”是个问题!我们的解决办法很简单:时钟不可逆,时钟版本号只能递增!每个键写入时的时间戳版本不能减少,只能变大。我们key的版本号不再是一个绝对的物理机时间戳,而是一个逻辑时钟,不能减。看上面的问题。当机器A设置数据“setcnt300”时,由于本地机器A的机器时间比较慢,所以得到的时间戳是58,而cnt本身的时间戳是100,此时在A机房写入输入操作的版本号会变小,肯定不会同步到B机房,如果代码发现此时cnt的版本号大于本机的时间戳,则版本号为递增到101,此时可以同步到B机房了。分布式逻辑时钟解决多个机房的一致性问题。6.3版本4的最终形式版本4和版本3的体系结构保持不变。变化在于增加分布式逻辑时钟的方法,不管多机房机器时间戳不一致,实现最终一致性。7、第5版经历了之前多个版本的迭代。这个时候应该没有问题。已经在生产环境中运行了很长时间,没有任何问题。但是,随着越来越多的业务接入,你会发现偶尔会再次出现数据不一致的情况。你要登入本机,发现服务器已经由主备切换。您在数据一致性方面做得还不够。master写入了数据a、b、c,同步过程已经同步到其他机房。如果此时master宕机,但是b和c还没有同步到复制备机,就会发生主备倒换。复制成为新的master,但是没有数据b和c,但是这两个数据还存在于其他机房。7.1主从切换数据不一致问题的根本原因是主备数据不一致,但是同步过程将不一致的数据同步到其他机房。如果每一个数据都存在副本中,那么同步到其他机房是没有问题的。这时候有两种解决方案:每次同步数据时,查询备机扫描进程查询备机,如果数据一致,再同步其他机房。显然,这样做的代价太大,会导致同步非常慢。如果查询延迟,扫描同步过程很容易卡顿,导致吞吐量大幅下降。Writeseq使用一个整数值来累加写操作。每次写操作加1,master和backup同时累加。同时这个seq也加入到binlogpipeline中。每个写流水线对应一个seq。比如master的seq是100,但是replica的seq是80,这时候我们的scan同步过程只需要将80之前的binlog流同步到其他机房就没有问题了,而80~100的binlogflow先不同步。7.2Version5的修改Version5和Version4的架构保持不变。变化是增加了seq的机制,保证服务器在主备切换的时候也能保证数据的一致性。8、由于版本6是同步多个机房的数据,如果多个机房同时进行累加操作,比如inccnt操作,因为数据同步是内存镜像操作。比如cnt的初始值为0,机房A是inccnt,机房B也是inccnt。通过以上同步机制得到的最终值可能是1或者2,但是用户需要的是2的值。8.1增加了多个机房的数值统计功能。上面统计出现问题的原因是多个机房累加或减去同一个key,然后同步内存镜像,会导致数据互相覆盖。所以我们的方案是分别统计每个机房的值,比如用hash结构,cnt是键名作为主键,每个机房的名字id作为子键。各机房独立累加或累加互不影响,从而在读取时得到正确的多方值。8.2版本的形式在这个版本中,我们增加了全局多机房的数值统计功能,整体结构不变。9、以上RedisPlus的所有功能,我们的中间件RedisPlus已经实现,可以使用了。以上就是OPPO异地多活实践缓存的全部内容。接下来,我们将推出《OPPO异地多活实践——逻辑层篇》,敬请期待。最后再发一条招聘信息:OPPO互联网基础技术团队正在大量招聘岗位,涵盖C++、Go、OpenJDK、Java、DevOps、Android、ElasticSearch等方向。请点击此处查看详细信息并联系我们。
