微信在立项之初,就已确立了利用数据版本号实现终端与后盾的数据增量同步机制,确保发消息时音讯牢靠送达对方手机,防止了大量潜在的家庭纠纷。时至今日,微信曾经走过第五个年头,这套同步机制依然在音讯收发、朋友圈告诉、好友数据更新等须要数据同步的中央施展着外围的作用。
而在这同步机制的背地,须要一个高可用、高牢靠的序列号生成器来产生同步数据用的版本号。这个序列号生成器咱们称之为 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 官方论坛