随着公司的业务发展,每次稳定性故障带来的影响越来越大,提供稳定的服务,保证系统的高可用已经变成了整个技术部面对的问题。基于这种背景,公司开展了多云/多活的技术项目,本人有幸参与了 “次日达” 项目【1】的异地双活改造方案的设计。想以此来浅谈一下我对多活乃至全球化的一些技术方案的认知。
多活架构系列的文章我会按照总体技术方案、双活/全球区域化部署技术、网络调度技术、性能优化以及SRE五大部分来展开。本篇毒家Blog会着重讨论总体技术方案以及双活/全球区域化部署技术中的路由服务设计模块,并会在后续的毒家Blog中逐步完善多活架构的完整技术方案。
网络服务除了要满足用户对性能、可用性等的基础要求外,多活/全球化的背景下还增加了合规、数据隔离性等要求。而这方方面面的要求都遇到了全新的挑战。
1. 性能
用户从发起请求到接收、响应的时效越短,代表性能越好。但是在双活/全球化的背景下,用户可能在日本,机房可能在中国,物理的距离变得更长了,对应服务的响应时效也会成正比。有测试数据显示,跨国家或者大范围的跨机房调用,网络的RTT会增加1s左右,而这1s可能会造成交易达成的“转化率”下降,甚至用户流失。
2. 可用性
多活\全球化的业务会跨越时区,这就要求我们的服务要7✖️24小时可用。这不仅是对系统的挑战,也是对人力的挑战。
3. 互联互通
互通互联指的是电信网络之间的物理连接。为了使一个电信运营商企业的用户可以和另一个电信运营商企业的用户进行相互通讯,在国内,这种跨越运营商的网络通讯已经不是什么大的问题。但是在国外,很多国家的网络互通互联的质量仍然不够理想。
4. 数据一致性
当数据被全球用户共享时,多地用户都可进行读写操作,如何确保数据的一致性?
5. 隐私保护
全球化的业务必须遵守GDRP(General Data Protection Regulation,通用数据保护条例)。
6. 可伸缩性
维基百科的解释:当系统、网络或者进程在任务量增减时,有能力进行应对。
系统的核心就是处理领域模型的关系,从领域模型出发让整个系统满足现状和未来的需求。同时让项目团队更好地协作,以下是系统的核心对象。
User:网站平台上的用户。
UserGroup:用户组、具有相同特征的用户一般会采用同样的网络链路和机房调度策略,因此会被归属到同一个用户组之中。实际上,多活系统是以用户组为基础调度单位的。用户是用户组的一员,我们既可以以用户组为单位调度,也可以针对某个具体用户调度。
EdgeNode:边缘节点。可以理解为静态资源的提供节点。比如图片、js文件、css等。一般的边缘节点指的是CDN。
NetLink:网络链路。
NetNode:网络节点。
PoP:网络服务接入点。(路由器、交换机等节点)
DSA:动态站点加速,是访问由CDN厂商提供动态内容的加速器。
IDC:机房。
DNS:DNS服务器。
HTTP-DNS:理解成APP端的DNS服务器。
DomainName:域名。
VIP:虚拟「偶像」。
以上领域模型的关系如下;
按照以上的描述,其实是可以看到,从用户出发一直到机房,会存在边缘节点调度、网络调度以及机房调度。还有调度的执行(路由的使用)以及调度的控制(路由的产生)。路由即每个用户或者每个用户组隶属于那个机房。 结构图如下;
全球用户:我们的系统会把全球用户分成不同的用户组,并且按照区域分为四组,即A洲、B洲、C洲和D洲。在调度执行时,常规的流程是把调度信息推送到每个用户组的 App 上。
Edge 调度:静态信息的调度,决定了每个用户组应该使用哪个边缘节点。
网络调度:基于大数据实时统计每一条可行的链路并且由决策模型确定走哪一条路线。(跟路由没关系)
机房:提供解决方案的源头。
调度执行:PC 使用 DNS 技术进行调度,App 使用 HTTP-DNS 以及 PUSH 技术调度。(路由的使用)
调度控制:通过实时数据计算,确定具体的调度。(路由的产生以及配置)
调度的编排具体策略由业务需求决定,通常来讲就是会考虑合规、数据一致性、可伸缩性、成本、容量、性能和稳定性来考虑。通常重要顺序是
合规 > 数据一致性 > 可伸缩性 > 其他
目前我们有四个机房(A洲、B洲、C洲、D洲),建设每个机房所在的地域服务对应的地域用户。机房之间的数据需要按需复制,每个机房都部署所有的应用以及数据库,使得每个机房都是对等的。当备份好数据之后,就可以让所有的机房互为灾备机房。数据一致性和可伸缩性会在后续介绍。
以电商场景中的买卖、就近访问以及异地容灾为例。比如买家和卖家分别来自不同的地区,进行交易必定会有部分共享的数据一致性问题;当异地容灾时,也会面临数据一致性问题;当用户迁移到其他区域时,仍然要保证就近访问,那么又涉及到同一个用户的数据一致性问题。
我们应对的策略如下:
就近访问:用户会路由到固定的机房(正常情况下),并且确保用户的数据都是在同一个机房闭环。
异地容灾:由于应用是对等的,那么就要确定数据在备份机房里存在。
全球买、卖:将数据按需同步,将商品信息同步到全部机房。
数据一致性:确保单一数据master原则,即同一条数据只有一个机房会进行变更。会确保业务的优先级(买家>卖家>运营)。
从应用程序分层的视角来看,解决方案如图;
区域化部署技术本质就是多层路由,而在每一层路由中,都是基于用户对应的归属机房调用的路由的。路由服务的作用就是告诉调用方,这个用户归属于哪个用户。
路由服务结构
内存路由表:理解为 HashMap,key 为用户 id,value 为用户归属机房以及用户状态。
RPC 服务。
路由表如何使用
用户请求进入机房第一个应用程序是同一接入层。使用 Nginx 作为统一接入的应用程序,Nginx 内嵌路由表,并且在多进程进行共享。Nginx 接受请求后做的第一件事情就是获取用户 id,然后调用路由表取得用户归属机房以及用户状态。若用户归属于本机房则继续向下透传。
下游需要路由信息直接获取上层丢下来的路由信息。如下图;
路由透传存在时效限制,当超出一定时效,透传内容会失效。至于在透传过程中,用户路由改变怎么办?本文后续解答。
路由表设计规范必须了解以下几点:
必须保存在内存。
保证性能和吞吐量。
不能依赖第三方系统。
路由设计应该支持自由升级。
4.2.1.1 方案比较
方案比较包括以下的引入分布式缓存、HashMap、布隆过滤器等,以下的方案各有缺点,具体如下。
4.2.1.1.1引入分布式缓存
缺陷
所有的系统都要调用远程缓存,依赖性强。
用户归属变更,客户端缓存要更新,远程的缓存也要更新。
各方系统都要加入一个强依赖。
4.2.1.1.2 HashMap
缺陷
保存5000万条大约需要2GB内存。
4.2.1.1.3 布隆过滤器
缺陷
存在False Positive。
这样看来没有一个现存的方案,需要根据场景来 定制 化路由表。
4.2.1.2 路由表设计
基于上述启发,选择使用比特数组进行存储路由信息。我们可以用4个 bit 来表达一个用户。如图所示;
这样存储的话存储一亿的数据只需要47M左右的内存空间。
但是如果用户ID的分布是分段的呢:
0~ 80000000
100000000~ 300000000
700000000~ 800000000
2000000000~ 2000100000
尽管真实的用户数量也只有1亿左右,但是id分布如此广泛,这样大约要消耗900多M的内存。
基于这点,要引入分段模式:
分段模式
分段模式如图所示,核心思路就是建立一个段索引表,每个索引表上指定了一段比特序列,用来保存用户信息。(例如我们以 100 万用户为一段)。针对这些索引项中一个用户都没有的,我们执行一个 NULL 段。其对应的比特序列也不会分配存储空间。这样大大的节省了内存空间。这样还是同样的用户登记才需要消耗 58 M左右的存储空间。
4.2.1.3 路由表相关设计
上阶段解决了路由表的基础存储方案,但是有一些场景还是需要我们持续设计改进的。现在我们思考两个问题:
当某个机房出现故障需要容灾切换时,如果基于现有的路由表实现方案,则需要变更对应机房所有用户的路由归属信息,可能涉及到几千万或者是上亿的用户变更,成本非常高。
在双十一的场景内,虽然可以通过大数据规划用户的行为分布,但是双十一一年才一次,学习样本少,很容易就会出现用户行为和预期不一致的现象,那就有可能会造成 A 洲机房的容量不足,但是美国的机房容量却很空余。此时就需要部分的 A 洲用户分流到美国机房,如果通过现在的路由表怎么支持呢?基于以上场景我们提出了一个叫做“逻辑机房”的概念。
当一切正常时,逻辑机房直接映射到一个原机房。
当发生容灾切换时,直接将逻辑机房映射到灾备机房。
当需要对部分用户进行分流时,按照用户 ID 进行 Hash 取模,将 Hash 结果不同的用户映射到不同的物理机房内。
具体的配置逻辑可以基于各个公司使用的配置系统来进行集中配置。
路由表更新机制的确立需要有以下设计约束;
数据一致性:在路由表变更的过程中,会出现一个用户的归属信息在不同的机房或者机器节点不一致的可能性。
可恢复、可回滚:无论系统处于什么状态,都可以确定性的恢复到一个期望状态。
快速变更:在一致性的保障过程,或者恢复、回滚的过程中,都会影响用户体验,甚至无法使用系统。所以在变更过程中需要在极短的时间内完成。
4.2.2.1 数据一致性思路
很多时候分布式系统都在解决一个问题,那就是如何让任何一条记录修改在所有机房或者多机房上同时生效。解决思路并不复杂,并且有通用性。虽然我们无法保证变更在所有机房或者多机房同时生效,但我们可以知道变更在多机房中是否已经生效,在此基础上我们设置一个中间状态,这个状态与变更前的状态和变更后的状态都兼容,就解决了这个问题。
如图所示,状态A是变更之前的状态,状态C是变更后的目标状态,状态A与状态C是不能同时出现的,但是状态A变更为状态B,在等待直到所有机房的所有相关机器全部都变更为状态B,那么再从B到状态C,这样就不会出现状态A和状态C同时出现的情况。
为了解决路由更新过程中业务数据全局一致性问题,我们引入了一个“禁写”过渡版本。在切换到目标路由机房之前,我们先将路由置为当前机房的“禁写”过渡版本,在这个状态下,用户不能继续在当前机房以及其他任何机房中进行任何会修改相关业务数据的动作。在“禁写”过渡版本变更到新版本之前,必须确保所有路由解析的本地版本已经升级到“禁写”过渡版本。“禁写”过渡版本将新旧路由版本的生效时间严格的隔离开来,不存在某个时刻新旧版本的路由都生效的情况,从而确保了业务数据的全局一致性
(注释:处在“禁写”过渡版本中的用户在“禁写”过程中的其他业务的可用性会受到一定程度的影响。这种影响应当被业务所接受,将其理解为一种业务可用性的局部临时降级。这种降级会安排在用户不活跃的时段,往往不会对用户的体验造成太大的影响。回归到路由表的视线中,用户Id是不需要被存储的,归属机房对应用户bit的前三位,可写标志对应的第4位,为0时表示用户可写,为1时表示用户禁写。)
4.2.2.2 解决方案
4.2.2.2.1 数据准备与生效过程分离
“禁写”状态会对用户产生影响,如果用户被禁写,则意味着用户无法下单,虽然在用户不活跃时段进行变更可以降低对用户产生影响的概率,但是变更可以在此基础上进一步降低这个概率。我们可以采用数据准备与生效过程分离的方式实现。
数据准备过程就是将用户归属的信息写入分布式持久数据库中。
由于要求快速回滚,因为必须是多版本的写入。这就要求我们持久层数据库的数据是多版本的。准备好数据后,对版本的生效过程采用Zookeeper的watch机制进行交互,过程如下:
当需要进行路由变更时,会由路由变更控制程序将数据写入数据库中,并且定义版本号。
数据准备好之后,将版本号写入Zookeeper的监听节点中,所有watch都会受到推送。
需要加载路由表的机器读取数据库内的数据,并进行新版的路由表加载。
4.2. 2.2.2 一致性具体方案
Zookeeper 是一种高性能的分布式协调工具,用于节点之间的通讯,常被使用分布式的配置管理中,各个厂商在路由表的数据一致性的建设中,大部分使用的也是这种解决方案。
在分布式协调场景中,常常会用到短暂节点,这个节点与创建他的session同在,当session消失,节点也会消失。这个机制常用于做心跳检查。而在路由节点的建设中,所有需要监听路由表的节点都会创建一个短暂节点,用于路由表加载节点的心跳检查。单次变更的流程如下:
所有的节点都会与Zk的currentVersion节点建立watcher,用于获取最新版本的推送。
所有的节点都会创建一个短暂节点,以机器名称命名,表示此节点正在监听变更,建立在SessionList目录下;当session消失时,表示这个节点不会在监听变更。
当节点被推送有新版本的变更后,它会使用这一个版本号去分布式数据库内查询数据(4.2.2.2.1之前已经准备好数据了)
当获取完成,并在本地内存中初始化路由表,会将机器名字作为节点写入AckList目录下的currentVersion子目录,表示此节点已经对于当前版本更新完成。
变更程序会比较AckList目录下的currentVersion子目录中的所有机器节点是否覆盖了SessionList目录下的所有机器节点,如果是则证明所有节点更新到最新版本。
因为我们知道所有节点是否已经更新完成,并且有与前后兼容的“禁写”状态,所以可以在所有节点都更新到“禁写”状态后,再进行新版本的路由信息变更,这样就可以确保出现的状态都是相互兼容的,从而保证了数据一致性问题。
上述步骤说明的ZK的目录节点结构如下:
4.2.2.3 整体架构
前面对关键的技术细节进行了介绍,下面介绍整体的架构。前面介绍过,管控系统会负责所有机房的区域化管理,包含路由表的变更流程。在每个机房中都会有一个管控的Agent,管控系统会调用Agent对所有的机房进行管理。在路由变更过程中,对每个Agent会把机房中的路由数据写入对应的分布式数据库中,Zk再推送信息写入,这里不再赘述。
4.2.2.3 变更流程
当路由表变更时,完整的流程变更如下:
保存当前版本号V1,用于处理回滚的方案。
获取当前机房列表,得到所有机房,循环调用每个机房的Agent,依次向下执行。
每个机房的Agent调用我们上述说的解决方案,将数据写入分布式数据库内。
如果失败,则直接调用第8步。
获取当前机房列表,得到所有机房,并且循环调用每个机房的Agent,将用户状态修改。
之后利用一致性的具体方案(4.2.2.2.2),并将所有用户状态改成最终状态。
如果失败,则直接调用第8步。如果成功,则流程结束。
循环调用每个机房的Agent,将版本回退。如果失败,则进行人工干预。
前面介绍过了路由表的更新机制,但是如何确定用户归属的机房?如何变更用户归属机房?如何将网站的存量用户加入到路由表中?以及有了新用户如何加入路由表中?
4.2.3.1 确定 用户归属机房
在真实应用场景中,绝大部分用户的归属逻辑采用性能优先原则,基本等同于用户归属于访问延迟最小的机房。当然对于大部分场景下,延迟最小的机房就是物理距离最近的机房。
我们如何来判断用户的归属机房,方案如下:
每个用户都会在所有机房进行异步访问,用于确认用户和所有机房的延迟。
以用户区域为粒度进行统计,最稳定的机房是哪一个。
在路由表中将区域中每个用户与这个区域整体表现最好的机房做关联。
4.2.3.2 变更用户归属机房
确定了用户归属机房之后,假设新的归属机房与原机房不同,那么就要落实一个用户到机房的归属。前面介绍过,在用户路由归属过程中,需要将表改写成向前向后都兼容的“禁写”状态,这个过程确保了路由表本身变更不会带来数据不一致的情况。但是从“禁写”用户到用户“可写”的过渡中,还需要将用户的数据在原机房复制到目标机房,并且确保复制完成。相关数据复制的技术这里不展开讨论,会在后续章节讲述。
4.2.3.3变更优化-分时变更
由于禁写可能会对用户产生影响,因此我们需要在变更的时间上进行优化,降低对用户生产影响的概率。主要的方法就是找到用户最可能闲时。
(1)以小时为单位,并且赋予时间段标识id。
(2)为用户的不同行为设置权重,权重代表禁写对用户影响的大小。
(3)建设用户 abc 在一段时间内操作记录如下,则采用下面的计算方法计算每个事件的冲突值。
P(0)=1/(1+2+1)*0.2+4/(4+2+2)*0.8=0.45 代表标识id为0的时间段冲突值为0.45
P(1)=2/(1+2+1)*0.2+4/(4+2+2)*0.8=0.3 代表标识id为1的时间段冲突值为0.3
P(2)=1/(1+2+1)*0.2+4/(4+2+2)*0.8=0.25 代表标识id为0的时间段冲突值为0.25
值越大,代表此时间段的避开价值就越大。
4.2.3.4 存量更新方案
存量更新方案是指两种场景
方案刚上线
机器刚启动
这两种场景一般都是指重新计算所有目前系统中存在的用户的归属机房。基于前面介绍的知识,目前采用的方案就是之前我们提到的确定用户归属机房(4.2.3.1)方案。
这里特殊提一下归属的默认优化,我们将某一个机房作为默认机房,所有归属到此机房的用户无需加入路由表,当调用路由服务查询此用户路由时,路由表返回空值,路由服务直接返回默认机房,从而大大降低路由表的大小。
4.2.3.5 全量更新方案
增量更新方案一般也指的是两个场景
用户注册
用户迁移
对于第一种情况,新机房的用户都会归属到默认机房,不进行任何路由表的变更,之后的过程与第二种情况相同。
对于第二种情况,在对新用户进行多机房探测过程,发现用户可能不应该属于本机房,或者发现新注册用户确实访问默认机房不是最快的。那么就需要做用户迁移,即进行增量更新。在确认归属之后,增量更新与存量更新方案一致,相比之下,增量更新方案需要变更的用户比较少。存量更新方案需要运行的次数并不多。
这一篇文章主要介绍了在异地多活/全球化改造过程中的基本概念以及领域建模还有路由系统的存储优化过程。后续还会持续更新异地多活/全球化的更多内容,欢迎关注「得物技术」公众号。
注释:
【1】次日达(Leadtime、LT)是一款得物推出的履约承诺产品,核心逻辑是通过发货园区、收货城市、商品属性匹配后台配置的线路,以此给用户承诺商品是否支持商品次日送达。
文|FUGUOFENG
关注得物技术,做最潮技术人!