单元化架构思考
架构演进
单体架构
单体架构最初就是打一个大包, 将UI、后台服务都打在一起。 早期的 ASP、JSP、PHP 技术体系中非常流行。
随着业务的发展, 架构的复杂性、流量的增长, 单体架构的逐渐产生了一些单点瓶颈:
- 应用数据库竞争
- 应用瓶颈
- 数据库瓶颈
应用数据库竞争
最开始应用和数据库部署在同一台机器很容易产生资源竞争
所以第一步就是把程序和数据库分开部署
应用瓶颈-集群部署
当流量增多后, 单应用部署已经不能满足服务要求了。
应用程序内部的性能调优外,从架构层面可以采用集群化部署模式,将大量的业务流量通过负载均衡服务来分流到多台应用服务器上处理。常见的负载均衡有F5,Nginx和云上的各种LB服务。
数据库扩容-读写分离
当应用扩容后, 压力就到了数据库, 在大部分场景下都是读多写少, 数据库第一步优化都是读写分离, 开启数据库的主从复制功能, 利用从库来分担度流量
数据库垂直拆分
随着业务的持续增长,主库已经难以支撑所有的写请求,这个阶段优先需要做的是数据库的垂直切分
譬如电商系统会按照业务领域拆成: 订单库、商品库、 用户库等多个数据库, 这样写请求就会按照各自的业务量分流到了不同的库上。不过这种拆分可能会引起分布式事务产生。如果存在一次事务跨了多个业务域的数据库,那就需要考虑多个数据库间的数据一致性问题。我们可以借助分布式数据库的产品能力解决,或在应用层引入分布式事务框架解决,也可以通过消息中间件实现最终一致性,取决于不同场景下的业务接受度。
备注: 这一步通常会将应用一起按照领域进行拆分(服务化)
数据库水平拆分
在系统按领域切分之后,如果某一领域内的业务量依然大到主库无法承载。那么接下去就需要在垂直拆分的数据库上再进行一次水平拆分。水平拆分可以将落到一台主库的压力分摊到多个分库上。这个阶段设计时需要注意数据路由和扩容场景:
数据一旦被按照一个维度进行了分库,那应用在请求时就需要在请求中带上该维度的值(通常称为分片键),进行路由判断,识别该数据在哪个分库上;
其次是扩容问题,假如当前为10个分库,一开始的路由算法为 hash(客户号)%10,后面扩容到12个分库,那计算规则就要变成 hash(客户号)%12。这样,所有分库上的数据都需要按照新的路由规则重新计算并进行数据迁移,这个过程称为“数据重分布”,成本和代价都很高。建议在设计阶段提前与各方约定好路由规则和扩容策略。
应用垂直拆分
在数据库按照业务领域垂直拆分的基础上, 一般应用也会垂直拆分(如上述例子, 会拆分成订单服务、商品服务、用户服务), 不同领域的业务连到各自领域的数据库上。
到这个阶段,最初的系统已经被按照不同业务领域,从设计到落地都实现了解耦。基于这种架构已经可以初步支持不同模块的独立迭代与演进,很贴近我们现在所了解的“微服务”架构。
应用SOA
把一个单体应用拆分为多个子应用时,随之而来的问题是这几个子应用间的通讯问题。在微服务架构之前,银行普遍采用消息总线(ESB)来做多个系统间的”衔接”。它就像一根通道,集成不同的应用、不同的协议,承担消息解释与路由职责,实现互联互通。属于早期分布式的经典架构。
微服务架构和SOA架构同样都是面向服务的架构设计。微服务更强调了去中心化思想,而ESB作为全行的消息总线在一定程度上是形成了单点和瓶颈。微服务架构就是从本质上去掉了ESB,通过注册中心来实现服务发现能力。以发布和订阅服务目录的方式,让应用间通过服务目录来实现点对点的直连通讯。
微服务架构
微服务架构需要具备的能力
SOA 架构已经与我们熟悉的微服务架构相似了: 业务按领域拆分,模块间相互解耦,具备独立演进的能力。
从单体应用往分布式架构演进的过程来看, 可以得出一个微服务架构所需要的能力:
- 由于系统被拆成多个服务,所以需要具备:服务注册和相互发现的能力(注册中心)
- 采用分布式部署后,多个服务间需要具备:统一的配置发布与热生效能力(配置中心)
- 系统拆分后有多个应用端点,对外需要:统一入口来收敛访问端点并提供统一鉴权、路由转发等能力(API网关)
- 为了提供可用性,采用了集群部署模式,所以需要具备:应用实例的故障发现与隔离能力(注册中心-健康检查)
- 集中式系统内部通过内存地址引用可以直接调用,在分布式架构下则会变成网络调用,因此需要具备:点对点通讯能力,由于服务都是集群形态,调用还需要支持软负载均衡的能力(服务治理或者说是RPC框架)
- 考虑到标准服务器的故障率、大量网络通信带来的稳定因素,以及可能存在的突发大规模流量等场景需求,分布式架构需要具备:服务的限流、熔断和降级等处置能力(限流、熔断、降级)
- 由于多模块间调用会导致系统请求链路的增长,降低了系统的性能与不稳定性,增加了运维复杂度,因此需要增加:全链路可观测与故障节点定位的能力(链路追踪或者说APM)
- 应用拆分部署后,由于一次交易变成了多进程访问数据库,从而产生事务一致性问题,需要具备:分布式事务能力(分布式事务方案)
在分布式架构下, 数据库应该具备的能力:
- 扩展数据库TPS, 读写分离
- 数据的水平拆分,数据库分片(中间件或者分布式数据库)
- 由于数据库的拆分(水平或垂直), 整体需要具备分布式事务以及查询聚合(OLAP)
大型微服务部署架构
对于一些有一定规模并且对稳定性、可用性有要求的业务, 一般都在集群化的基础上再进行扩展: 即多活架构
对于大部分有异地多活的业务来说, 两地三中心是一个标准的选择, 即: 同城为双活,异地为灾备,一共三个数据中心
上图是一个标准的两地三中心部署架构, 流量从全局负载均衡(或DNS解析)进入到同城两个数据中心,到微服务网关层时, 网关通过注册中心获取目标服务的可用地址,基于软负载策略发起点对点调用。
应用在处理数据时,由于数据库主库是单边活(分布式数据库一样也是架构层面实现数据多点可用,而并非是一份数据在多点同时多活),所以同城中心的应用有可能需要跨中心访问到另一个中心进行数据操作。
这个架构在极端情况下可能存在两个问题:
- 应用服务之间的跨机房调用
- 应用与数据库之间的跨机房调用
以目前的网络基础设施情况,同城内多个机房间的网络情况在大部分情况下稳定性和性能都是有保障的,50公里的延时一般都在1ms以下。这对于跨中心访问来说问题并不大,足以应对大部分普通场景.
当业务出于高峰期的时候, QPS在十万以上, 抖动和延时的问题就会对业务造成一定的影响, 在服务层面可以通过微服务框架的调度策略如就近路由等来避免, 但是在数据库层面就没办法完全避免了。
常见分布式架构
单元化架构
单元化架构简介
单元化架构就是把单元作为部署的基本单位,在全站所有机房中部署数个单元,每个机房里的单元数目不定,任意一个单元 都部署了系统所需的所有应用,数据则是全量数据按照某种维度划分后的一部分。
单元化架构希望在分布式架构上解决如下问题:
- 异地场景下的访问延时问题,实现异地多活。
- 单机房数据库连接限制问题,突破物理限制。
- 容量预估和扩容复杂问题,按单元预估和扩容。
单元化架构的核心原则是单元化流量封闭,包含以下几点:
- 核心业务是可分片的, 其中最重要的是数据层面的分片: 粒度合适、足够平均
- 单元化架构下,服务仍然是分层的,不同的是每一层中的任意一个节点都属于且仅属于某一个单元,上层调用下层时,仅会选择本单元内的节点
- 核心业务尽量自包含,调用尽量封闭。
- 整个系统要面向逻辑分区设计,而不是物理部署
单元化架构基础术语
这里参考的是蚂蚁的定义, 不同的厂商有不同的叫法和实施细则。
- 单元: 应用层按照数据层相同的拆片维度,把整个请求链路收敛在一组服务器中,从应用层到数据层就可以组成一个封闭的单元。数据库只需要承载本单元的应用节点的请求,大大节省了连接数。 单元可以作为一个相对独立整体来挪动,甚至可以把部分单元部署到异地去
- 逻辑单元: 逻辑单元是单元化架构的基础,一个逻辑单元被称为一个 Zone。根据业务特点不同,您可以将系统部署在不同类型的逻辑单元中。有 3 种不同类型:RZone、GZone、CZone。
- RZone(Region Zone): 核心业务和数据单元化拆分,拆分后分片均衡,单元内尽量自包含(调用封闭),拥有自己的数据,能完成所有业务。 一个可用区可以有多个 RZone
- GZone(Global Zone): 无法拆分的业务,如配置型的业务。数据库可以和 RZone 共享,全局只有一组,可以配置流量权重。会被 RZone 依赖
- CZone(City Zone): 为了解决异地延迟问题而特别设计,适合读多写少且不可拆分的业务, 被 RZone 依赖(与GZone 类似, 不过GZone 被 RZone 的访问频率低很多)
- 路由规则: 单元化架构下的路由规则是一个 json 字符串,包含了路由信息、灾备信息、机房部署信息、灰度信息等,主要用于 http 请求转发、rpc 路由计算、msg 目标 Zone 计算、zdal 数据源连接、zcache 集群选择等
- Uid: 也叫 sharding key,指应用层和数据层的拆分维度,在 LHC(LDC Hybrid Cloud 单元化应用) 里可以指定 UID 分片与部署单元的映射关系。在实际 使用过程中 UID 可以对应着多种类型的数据,如用户 ID、交易流水,只要能映射到分片即可
经典部署结构如上, 需要注意的是:
- RZone 是成组部署的,组内 A/B 集群互为备份,可随时调整 A/B 之间的流量比例。可以把一组 RZone 部署到任意机房中,包括异地机房,数据随着 Zone 一起走
- GZone 也是成组部署的,A/B 互备,同样可以调整流量。GZone 只有一组,必须部署在同一个城市中
- CZone 是一种很特殊的 zone,它是为了解决最让人头疼的异地延时问题而诞生的,可以说是支付宝单元化架构的一个创新。 CZone 解决这个问题的核心思想是:把数据搬到本地,并基于一个假设:大部分数据被创建(写入)和被使用(读取)之间是有时间差的:
- 把数据搬到本地:在某个机房创建或更新的公共数据,以增量的方式同步给异地所有机房,并且同步是双向的,也就是说在大多数时间,所有机房里的公共数据库,内容都是一样的。这就使得部署在任何城市的 RZone,都可以在本地访问公共数据,消除了跨地访问的影响。整个过程中唯一受到异地延时影响的,就只有数据同步,而这影响,也会被下面所说的时间差抹掉。
- 时间差假设:举例说明,2 个用户分属两个不同的 RZone,分别部署在两地,用户 A 要给用户 B 做一笔转账,系统处理时必须同时拿到 A 和 B 的会员信息;而 B 是一个刚刚新建的用户,它创建后,其会员信息会进入它所在机房的公共数据库,然后再同步给 A 所在的机房。如果 A 发起转账的时候,B 的信息还没有同步给 A 的机房,这笔业务就会失败。时间差假设就是,对于 80% 以上的公共数据,这种情况不会发生,也就是说 B 的会员信息创建后,过了足够长的时间后,A 才会发起对 B 的转账。
蚂蚁金服基于OB的单元化架构
蚂蚁金服分享的基于分布式数据库 OceanBase 的一种单元化架构
可以看到这是一个三地五中心的单元化架构, 数据层基于分布式数据库OB(腾讯云 TDSQL 也是类似), 整体呈现:
- 整体架构由 RZone、GZone和 CZone 组成, 其中 GZone 部署的是无法拆分的数据和业务,GZone 的数据和业务被 RZone 依赖,GZone全局只部署一份,而RZone 部署的是可拆分的业务和对应的数据。
- CZone 从 GZone 拷贝数据,不同城市的 RZone 可能依赖 GZone 服务和数据的时候需要远距离调用,延迟比较大,所以在每个城市部署一个CZone作为GZone的只读副本。
- 每个 RZone 都有全部数据,但个可写入的主副本,其余副本按照Paxos协议做数据强一致。 数据分片维度呈现 三地五中心 , 数据冗余度比较高(也保障了数据不会丢失)
- CZone 和 GZone 是独立的 OB 集群(类似于 TDSQL 的广播表, 分布式数据库的能力都比较相似)
单元化架构下机房级别容灾
容灾能力分为同城容灾和异地容灾。
同城容灾,RZone1 出现故障切换到同城 RZone2:
- 先做数据库分片切换,RZone1 对应的分片为分片1,把分片1在 RZone2 的副本提升为主副本
- 数据库副本提升完毕后将 RZone1 的流量切换至 RZone2
异地容灾,RZone1 出现故障切换到异地 RZone3:
- 先做数据库切换,分片1在 RZone3 的副本切换成主副本
- 完成后将 RZone1 的流量切换至 RZone3
可以看到, 容灾基本是基于底层DB的容灾, 先切DB, 再切流量。 这里高度依赖数据库的能力, 整体 RPO(Recovery Point Objective 数据恢复点目标)=0(不会造成数据丢失), RTO(Recovery Time Objective 恢复时间目标) < 1min(机房级容灾在分钟级,肯定是能接受的了)
技术组件-分布式数据库
从上面的架构不难看出来, 单元化架构真正核心的还是底层的分布式数据库(毕竟应用是无状态的, 中间件可以多集群部署), 这里以个人对腾讯云分布式数据库的理解, 阐述一下分布式数据库的一些核心能力。
分布式数据库架构
首先核心架构如下:
系统由三个模块组成:Keeper,Agent,网关,三个模块的交互都是通过zookeeper完成.
Keeper作为集群的管理调度中心,主要功能:
- 管理set,提供创建,删除set,set内节点替换等工作;
- 监控set内各个节点的存活状态,当Set内主节点故障,发起高一致性主备切换流程,
Agent模块负责监控本机MySQL实例的运行情况,主要功能:
- 用短连接的方式周期性访问本机的MySQL实例,检测是否可读,可写,如发生异常,会将异常信息上报到Zookeeper,最终会由上面描述的Keeeper模块检测到这个异常情况,从而发起容灾切换
- 检测主备复制的执行情况,会定期上报主备复制的延时和延迟的事务数,
- 检测MySQL实例的CPU利用率和各个表的请求量,数据量,cpu利用率,上报到Zookeeper
- 监控是否有下发到自身的扩容任务,如有则会执行扩容流程
- 监控是否要发生容灾切换,并按计划执行主备切换流程。
网关基于 MySQL Proxy 开发,在网络层,连接管理,SQL解析,路由等方面做了大量优化,主要特点和功能:
- 将SQL请求路由到对应的Set,支持读写分离;
- 对接入的IP,用户名,密码进行鉴权;
- 记录完整的SQL执行信息,与秒级监控平台对接完成实时的SQL请求的时耗,成功率等指标监控分析;
- 对 count,distinct,sum,avg,max,min,order by,group by 等聚合类 SQL 一般需要访问后端的多个 Set ,网关会分析结果并做合并再返回
扩容流程
在介绍扩容流程之前, 先需要先看看分表逻辑:
- 每个表(逻辑表)可能会拆分成多个子表(通过分片键拆分, 又叫 ShardKey),每个子表在 MySQL 上都是一个真实的物理表,这里称为一个 shard
- 一张表的数据就按照 shard 的拆分, 分布在不同的 Set(这里可以理解为是一个DB实例) 当中, 如下:
TDSQL 扩容采取的是先搬后切的策略: 让请求继续在原Set交易,扩容程序首先记录一个binlog位置点,并将源Set中符合迁移条件的数据全部迁移出去,最后再将搬迁过程中新增的binlog追完,最后修改路由规则,将请求发送到新Set。
与之对应的测量是先切后搬: 先修改路由,将需要迁走的数据的请求直接发送到新Set,在新Set交易过程中如发现本地的数据不存在,则去原Set拉取数据,然后再通过一些离线的策略将要迁移的数据全量再搬迁一次。 先搬后切的好处是: 回滚非常方便,如有异常直接干掉扩容任务即可, 难点则是追路由阶段需要更多的精细化控制
一个具体的扩容流程, 假如要将Set1中的t_shard_1的数据迁移一半到Set4中的t_shard_4(1667-3333):
Keeper首先在 Set4 中创建好表 t_shard_4;
后将扩容任务下发到 Set1 中的 agent 模块,agent 检测到扩容任务之后会采用
mysqldump+where
条件的方式将 t_shard_1 中 shard 号段为 1667-3333 的记录导出来并通过管道用并行的方式插入到Set4(不会在本地存文件,避免引起过多的IO),用 mysqldump 导出镜像的时候会有一个 binlog 位置;从 mysqldump 记录的 binlog 位置开始读取 binlog 并插入到到 Set4,追到所有binlog文件末尾的时候(这需要一个循环,每次循环记录从开始追 binlog 截止到追到文件结尾消耗的时间,必须保证追单次循环要在几秒之内完成,避免遗留的 binlog 太多导致最后一次追 binlog 消耗太多的时间,从而影响业务过久),对原来的表 t_shard_1 重命名 t_shard_5,此时针对这个表不会再有新请求,如还有请求过来都会失败,然后再追一次 binlog到文件结尾(因为上面的循环保证了追binlog不会太耗时间了,所以此次会快速完成),然后上报状态到zookeeper,表明扩容任务完成;
Keeper 收到扩容完成的信息之后会修改路由表,最后由网关拉取到新路由完成整体的扩容;从表重命名开始到网关拉取到新路由,这段时间这个原始 Shard 不可用(这个不可用的时间非常短,只有几百毫秒), 如果某个网关异常,拉取不到新路由,继续访问老表 t_shard_1 会一直失败,这样就可以保证数据的一致性。
以上就是分布式数据库对数据拆分管理的一个实现方案了, 当然这个版本比较粗糙, 分布式数据库还要考虑: 分布式事务、OLAP场景优化等(也就是数据拆分后的数据合并)
业界已经有比较多的优秀分布式数据库了: 如蚂蚁的 ob, ali 的 polarDB, 包括 pingCAP 的 tidb 也是有比较多的公司会选择
技术组件-单元化流量管控
流量管控也是单元化的一个重要实现基础, 如何保证一条数据进入自己归属的那个 RZ, 这里也借助蚂蚁分享的思想: 多层防线, DAL 层兜底
- DNS 层照理说感知不到任何业务层的信息,蚂蚁使用的一个优化叫”多域名技术”。 比如 PC 端收银台的域名是 cashier.alipay.com,在系统已知一个用户数据属于哪个单元的情况下,就让其直接访问一个单独的域名,直接解析到对应的数据中心,避免了下层的跨机房转发。例如上图中的 cashiergtj.alipay.com,gtj 就是内部一个数据中心的编号。移动端也可以靠下发规则到客户端来实现类似的效果。
- 反向代理层是基于 Nginx 二次开发的,后端系统在通过参数识别用户所属的单元之后,在 Cookie 中写入特定的标识。下次请求,反向代理层就可以识别,直接转发到对应的单元。
- 网关 /Web 层是应用上的第一道防线,是真正可以有业务逻辑的地方。在通用的 HTTP 拦截器中识别 Session 中的用户 ID 字段,如果不是本单元的请求,就 forward 到正确的单元。并在 Cookie 中写入标识,下次请求在反向代理层就可以正确转发。
- 服务层 RPC 框架和注册中心内置了对单元化能力的支持,可以根据请求参数,透明地找到正确单元的服务提供方。
- 数据访问层是最后的兜底保障,即使前面所有的防线都失败了,一笔请求进入了错误的单元,在访问数据库的时候也一定会去正确的库表,最多耗时变长,但绝对不会访问到错误的数据。