微信在立项之初,就已确立了利用数据版本号实现终端与后盾的数据增量同步机制,确保发消息时音讯牢靠送达对方手机,防止了大量潜在的家庭纠纷。时至今日,微信曾经走过第五个年头,这套同步机制依然在音讯收发、朋友圈告诉、好友数据更新等须要数据同步的中央施展着外围的作用。
而在这同步机制的背地,须要一个高可用、高牢靠的序列号生成器来产生同步数据用的版本号。这个序列号生成器咱们称之为seqsvr,目前曾经倒退为一个每天万亿级调用的重量级零碎,其中每次申请序列号平时调用耗时1ms,99.9%的调用耗时小于3ms,服务部署于数百台4核CPU服务器上。本文会重点介绍seqsvr的架构核心思想,以及seqsvr随着业务量疾速上涨所做的架构演变。
背景
微信服务器端为每一份须要与客户端同步的数据(例如音讯)都会赋予一个惟一的、递增的序列号(后文称为sequence),作为这份数据的版本号。在客户端与服务器端同步的时候,客户端会带上曾经同步上来数据的最大版本号,后盾会依据客户端最大版本号与服务器端的最大版本号,计算出须要同步的增量数据,返回给客户端。这样不仅保障了客户端与服务器端的数据同步的可靠性,同时也大幅缩小了同步时的冗余数据。
这里不必乐观锁机制来生成版本号,而是应用了一个独立的seqsvr来解决序列号操作,一方面因为业务有大量的sequence查问需要——查问曾经调配进来的最初一个sequence,而基于seqsvr的查问操作能够做到十分轻量级,防止对存储层的大量IO查问操作;另一方面微信用户的不同品种的数据存在不同的Key-Value零碎中,应用对立的序列号有助于防止反复开发,同时业务逻辑能够很不便地判断一个用户的各类数据是否有更新。
从seqsvr申请的、用作数据版本号的sequence,具备两种根本的性质:
- 递增的64位整型变量
- 每个用户都有本人独立的64位sequence空间
举个例子,小明以后申请的sequence为100,那么他下一次申请的sequence,可能为101,也可能是110,总之肯定大于之前申请的100。而小红呢,她的sequence与小明的sequence是独立开的,如果她以后申请到的sequence为50,而后期间不论小明申请多少次sequence怎么折腾,都不会影响到她下一次申请到的值(很可能是51)。
这里用了每个用户独立的64位sequence的体系,而不是用一个全局的64位(或更高位)sequence,很大起因是全局惟一的sequence会有十分重大的申请互斥问题,不容易去实现一个高性能高牢靠的架构。对微信业务来说,每个用户独立的64位sequence空间曾经满足业务要求。
目前sequence用在终端与后盾的数据同步外,同时也宽泛用于微信后盾逻辑层的根底数据一致性cache中,大幅缩小逻辑层对存储层的拜访。尽管一个用于终端——后盾数据同步,一个用于后盾cache的一致性保障,场景大不相同。
但咱们仔细分析就会发现,两个场景都是利用sequence牢靠递增的性质来实现数据的一致性保障,这就要求咱们的seqsvr保障调配进来的sequence是稳固递增的,一旦呈现回退必然导致各种数据错乱、音讯隐没;另外,这两个场景都十分广泛,咱们在应用微信的时候会人不知;鬼不觉地对应到这两个场景:小明给小红发消息、小红拉黑小明、小明发一条失恋状态的朋友圈,一次简略的离别背地可能申请了无数次sequence。
微信目前领有数亿的沉闷用户,每时每刻都会有海量sequence申请,这对seqsvr的设计也是个极大的挑战。那么,既要sequence牢靠递增,又要能顶住海量的拜访,要如何设计seqsvr的架构?咱们先从seqsvr的架构原型说起。
架构原型
不思考seqsvr的具体架构的话,它应该是一个微小的64位数组,而咱们每一个微信用户,都在这个大数组里独占一格8bytes的空间,这个格子就放着用户曾经调配进来的最初一个sequence:cur_seq。每个用户来申请sequence的时候,只须要将用户的cur_seq+=1,保留回数组,并返回给用户。
图1. 小明申请了一个sequence,返回101
预调配中间层
任何一件看起来很简略的事,在海量的访问量下都会变得不简略。前文提到,seqsvr须要保障调配进来的sequence递增(数据牢靠),还须要满足海量的访问量(每天靠近万亿级别的拜访)。满足数据牢靠的话,咱们很容易想到把数据长久化到硬盘,然而依照目前每秒千万级的访问量(~10^7 QPS),根本没有任何硬盘零碎能扛住。
后盾架构设计很多时候是一门对于衡量的哲学,针对不同的场景去思考能不能升高某方面的要求,以换取其它方面的晋升。认真思考咱们的需要,咱们只要求递增,并没有要求间断,也就是说呈现一大段跳跃是容许的(例如调配出的sequence序列:1,2,3,10,100,101)。于是咱们实现了一个简略优雅的策略:
- 内存中贮存最近一个调配进来的sequence:cur_seq,以及调配下限:max_seq调配sequence时,将cur_seq++,同时与调配下限max_seq比拟:如果cur_seq > max_seq,将调配下限晋升一个步长max_seq += step,并长久化max_seq重启时,读出长久化的max_seq,赋值给cur_seq
图2. 小明、小红、小白都各自申请了一个sequence,但只有小白的max_seq减少了步长100
这样通过减少一个预调配sequence的中间层,在保障sequence不回退的前提下,大幅地晋升了调配sequence的性能。理论利用中每次晋升的步长为10000,那么长久化的硬盘IO次数从之前~10^7 QPS升高到~10^3 QPS,处于可承受范畴。在失常运作时调配进来的sequence是程序递增的,只有在机器重启后,第一次调配的sequence会产生一个比拟大的跳跃,跳跃大小取决于步长大小。
分号段共享存储
申请带来的硬盘IO问题解决了,能够反对服务安稳运行,但该模型还是存在一个问题:重启时要读取大量的max_seq数据加载到内存中。
咱们能够简略计算下,以目前uid(用户惟一ID)下限2^32个、一个max_seq 8bytes的空间,数据大小一共为32GB,从硬盘加载须要不少工夫。另一方面,出于数据可靠性的思考,必然须要一个牢靠存储系统来保留max_seq数据,重启时通过网络从该牢靠存储系统加载数据。如果max_seq数据过大的话,会导致重启时在数据传输破费大量工夫,造成一段时间不可服务。
为了解决这个问题,咱们引入号段Section的概念,uid相邻的一段用户属于一个号段,而同个号段内的用户共享一个max_seq,这样大幅缩小了max_seq数据的大小,同时也升高了IO次数。图3. 小明、小红、小白属于同个Section,他们共用一个max_seq。在每个人都申请一个sequence的时候,只有小白冲破了max_seq下限,须要更新max_seq并长久化
目前seqsvr一个Section蕴含10万个uid,max_seq数据只有300+KB,为咱们实现从牢靠存储系统读取max_seq数据重启打下基础。
工程实现
工程实现在下面两个策略上做了一些调整,次要是出于数据可靠性及劫难隔离思考
- 把存储层和缓存中间层分成两个模块StoreSvr及AllocSvr。StoreSvr为存储层,利用了多机NRW策略来保证数据长久化后不失落;AllocSvr则是缓存中间层,部署于多台机器,每台AllocSvr负责若干号段的sequence调配,摊派海量的sequence申请申请。
- 整个零碎又按uid范畴进行分Set,每个Set都是一个残缺的、独立的StoreSvr+AllocSvr子系统。分Set设计目标是为了做劫难隔离,一个Set呈现故障只会影响该Set内的用户,而不会影响到其它用户。
图4. 原型架构图
容灾设计
接下来咱们会介绍seqsvr的容灾架构。咱们晓得,后盾零碎绝大部分状况下并没有一种惟一的、完满的解决方案,同样的需要在不同的环境背景下甚至有可能演化出两种截然不同的架构。既然架构是多变的,那纯正讲架构的意义并不是特地大,期间也会讲下seqsvr容灾设计时的一些思考和衡量,心愿对大家有所帮忙。
seqsvr的容灾模型在五年中进行过一次比拟大的重构,晋升了可用性、机器利用率等方面。其中不论是重构前还是重构后的架构,seqsvr始终遵循着两条架构设计准则:
- 放弃本身架构简略
- 防止对外部模块的强依赖
这两点都是基于seqsvr可靠性思考的,毕竟seqsvr是一个与整个微信服务端失常运行非亲非故的模块。依照咱们对这个世界的意识,零碎的复杂度往往是跟可靠性成反比的,想得到一个牢靠的零碎一个关键点就是要把它做简略。置信大家身边都有一些这样的例子,设计方案里有很多高大上、简单的货色,同时也总能看到他们在默默地填一些高大上的坑。当然简略的零碎不意味着粗制滥造,咱们要做的是理出最外围的点,而后在满足这些外围点的根底上,针对性地提出一个足够简略的解决方案。
那么,seqsvr最外围的点是什么呢?每个uid的sequence申请要递增不回退。这里咱们发现,如果seqsvr满足这么一个束缚:任意时刻任意uid有且仅有一台AllocSvr提供服务,就能够比拟容易地实现sequence递增不回退的要求。
图5. 两台AllocSvr服务同个uid造成sequence回退。Client读取到的sequence序列为101、201、102
但也因为这个束缚,多台AllocSvr同时服务同一个号段的多主机模型在这里就不实用了。咱们只能采纳单点服务的模式,当某台AllocSvr产生服务不可用时,将该机服务的uid段切换到其它机器来实现容灾。这里须要引入一个仲裁服务,探测AllocSvr的服务状态,决定每个uid段由哪台AllocSvr加载。出于可靠性的思考,仲裁模块并不间接操作AllocSvr,而是将加载配置写到StoreSvr长久化,而后AllocSvr定期拜访StoreSvr读取最新的加载配置,决定本人的加载状态。
图6. 号段迁徙示意。通过更新加载配置把0~2号段从AllocSvrA迁徙到AllocSvrB
同时,为了防止失联AllocSvr提供谬误的服务,返回脏数据,AllocSvr须要跟StoreSvr放弃租约。这个租约机制由以下两个条件组成:
- 租约生效:AllocSvr N秒内无奈从StoreSvr读取加载配置时,AllocSvr进行服务
- 租约失效:AllocSvr读取到新的加载配置后,立刻卸载须要卸载的号段,须要加载的新号段期待N秒后提供服务
图7. 租约机制。AllocSvrB严格保障在AllocSvrA进行服务后提供服务
这两个条件保障了切换时,新AllocSvr必定在旧AllocSvr下线后才开始提供服务。但这种租约机制也会造成切换的号段存在小段时间的不可服务,不过因为微信后盾逻辑层存在重试机制及异步重试队列,小段时间的不可服务是用户无感知的,而且呈现租约生效、切换是小概率事件,整体上是能够承受的。
到此讲了AllocSvr容灾切换的基本原理,接下来会介绍整个seqsvr架构容灾架构的演变
容灾1.0架构:主备容灾
最后版本的seqsvr采纳了主机+冷备机容灾模式:全量的uid空间平均分成N个Section,间断的若干个Section组成了一个Set,每个Set都有一主一备两台AllocSvr。失常状况下只有主机提供服务;在主机出故障时,仲裁服务切换主备,原来的主机下线变成备机,原备机变成主机后加载uid号段提供服务。图8. 容灾1.0架构:主备容灾
可能看到前文的叙述,有些同学曾经想到这种容灾架构。一主机一备机的模型设计简略,并且具备不错的可用性——毕竟主备两台机器同时不可用的概率极低,置信很多后盾零碎也采纳了相似的容灾策略。
设计衡量
主备容灾存在一些显著的缺点,比方备机闲置导致有一半的闲暇机器;比方主备切换的时候,备机在霎时要承受主机所有的申请,容易导致备机过载。既然一主一备容灾存在这样的问题,为什么一开始还要采纳这种容灾模型?事实上,架构的抉择往往跟过后的背景无关,seqsvr诞生于微信倒退初期,也正是微信疾速扩张的时候,抉择一主一备容灾模型是出于以下的思考:
- 架构简略,能够疾速开发
- 机器数少,机器冗余不是次要问题
- Client端更新AllocSvr的路由状态很容易实现
前两点好懂,人力、机器都不如工夫贵重。而第三点比拟有意思,上面开展讲下
微信后盾绝大部分模块应用了一个自研的RPC框架,seqsvr也不例外。在这个RPC框架里,调用端读取本地机器的client配置文件,决定去哪台服务端调用。这种模型对于无状态的服务端,是很好用的,也很不便实现容灾。咱们能够在client配置文件外面写“对于号段x,能够去SvrA、SvrB、SvrC三台机器的任意一台拜访”,实现三主机容灾。
但在seqsvr里,AllocSvr是预调配中间层,并不是无状态的。而后面咱们提到,AllocSvr加载哪些uid号段,是由保留在StoreSvr的加载配置决定的。那么这时候就难堪了,业务想要申请某个uid的sequence,Client端其实并不分明具体去哪台AllocSvr拜访,client配置文件只会跟它说“AllocSvrA、AllocSvrB…这堆机器的某一台会有你想要的sequence”。换句话讲,原来负责提供服务的AllocSvrA故障,仲裁服务决定由AllocSvrC来代替AllocSvrA提供服务,Client要如何获知这个路由信息的变更?
这时候如果咱们的AllocSvr采纳了主备容灾模型的话,事件就变得简略多了。咱们能够在client配置文件里写:对于某个uid号段,要么是AllocSvrA加载,要么是AllocSvrB加载。Client端发动申请时,只管Client端并不分明AllocSvrA和AllocSvrB哪一台真正加载了指标uid号段,然而Client端能够先尝试给其中任意一台AllocSvr发申请,就算这次申请了谬误的AllocSvr,那么就晓得另外一台是正确的AllocSvr,再发动一次申请即可。
也就是说,对于主备容灾模型,最多也只会节约一次的试探申请来确定AllocSvr的服务状态,额定耗费少,编码也简略。可是,如果Svr端采纳了其它简单的容灾策略,那么基于动态配置的框架就很难去确定Svr端的服务状态:Svr产生状态变更,Client端无奈确定应该向哪台Svr发动申请。这也是为什么一开始抉择了主备容灾的起因之一。
主备容灾的缺点
在咱们的理论经营中,容灾1.0架构存在两个重大的有余:
- 扩容、缩容十分麻烦
- 一个Set的主备机都过载,无奈应用其余Set的机器进行容灾
- 在主备容灾中,Client和AllocSvr须要应用完全一致的配置文件。变更这个配置文件的时候,因为无奈实现在同一时间更新给所有的Client和AllocSvr,因而须要非常复杂的人工操作来保障变更的正确性(包含须要应用iptables来做申请转发,具体的详情这里不做开展)。
对于第二个问题,常见的办法是用一致性Hash算法代替主备,一个Set有多台机器,过载机器的申请被摊派到多台机器,容灾成果会更好。在seqsvr中应用相似一致性Hash的容灾策略也是可行的,只有Client端与仲裁服务都应用齐全一样的一致性Hash算法,这样Client端能够启发式地去尝试,直到找到正确的AllocSvr。
例如对于某个uid,仲裁服务会优先把它调配到AllocSvrA,如果AllocSvrA挂掉则调配到AllocSvrB,再不行调配到AllocSvrC。那么Client在拜访AllocSvr时,依照AllocSvrA -> AllocSvrB -> AllocSvrC的程序去拜访,也能实现容灾的目标。但这种办法依然没有克服后面主备容灾面临的配置文件变更的问题,经营起来也很麻烦。
容灾2.0架构:嵌入式路由表容灾
最初咱们另辟蹊径,采纳了一种不同的思路:既然Client端与AllocSvr存在路由状态不统一的问题,那么让AllocSvr把以后的路由状态传递给Client端,突破之前只能依据本地Client配置文件做路由决策的限度,从根本上解决这个问题。
所以在2.0架构中,咱们把AllocSvr的路由状态嵌入到Client申请sequence的响应包中,在不带来额定的资源耗费的状况下,实现了Client端与AllocSvr之间的路由状态统一。具体实现计划如下:
seqsvr所有模块应用了对立的路由表,形容了uid号段到AllocSvr的全映射。这份路由表由仲裁服务依据AllocSvr的服务状态生成,写到StoreSvr中,由AllocSvr当作租约读出,最初在业务返回包里旁路给Client端。
图9. 容灾2.0架构:动静号段迁徙容灾
把路由表嵌入到申请响应包看似很简略的架构变动,却是整个seqsvr容灾架构的技术奇点。利用它解决了路由状态不统一的问题后,能够实现一些以前不容易实现的个性。例如灵便的容灾策略,让所有机器都互为备机,在机器故障时,把故障机上的号段平均地迁徙到其它可用的AllocSvr上;还能够依据AllocSvr的负载状况,进行负载平衡,无效缓解AllocSvr申请不均的问题,大幅晋升机器使用率。
另外在经营上也失去了大幅简化。之前对机器进行运维操作有着繁冗的操作步骤,而新架构只须要更新路由即可轻松实现上线、下线、替换机器,不须要关怀配置文件不统一的问题,防止了一些因为人工误操作引发的故障。
图10. 机器故障号段迁徙
路由同步优化
把路由表嵌入到取sequence的申请响应包中,那么会引入一个相似“先有鸡还是先有蛋”的哲学命题:没有路由表,怎么晓得去哪台AllocSvr取路由表?另外,取sequence是一个超高频的申请,如何防止嵌入路由表带来的带宽耗费?
这里通过在Client端内存缓存路由表以及路由版本号来解决,申请步骤如下:
- Client依据本地共享内存缓存的路由表,抉择对应的AllocSvr;如果路由表不存在,随机抉择一台AllocSvr
- 对选中的AllocSvr发动申请,申请带上本地路由表的版本号
- AllocSvr收到申请,除了解决sequence逻辑外,判断Client带上版本号是否最新,如果是旧版则在响应包中附上最新的路由表
- Client收到响应包,除了解决sequence逻辑外,判断响应包是否带有新路由表。如果有,更新本地路由表,并决策是否返回第1步重试
基于以上的申请步骤,在本地路由表生效的时候,应用大量的重试便能够拉到正确的路由,失常提供服务。
总结
到此把seqsvr的架构设计和演变根本讲完了,正是如此简略优雅的模型,为微信的其它模块提供了一种简略牢靠的一致性解决方案,撑持着微信五年来的高速倒退,置信在可预感的将来依然会施展着重要的作用。
本文系微信后盾团队,如有进犯,请分割咱们立刻删除
咱们的官网及论坛:
OpenIM官网
OpenIM官方论坛