说到大规模微服务系统,往往是一些 7*24 时不间断运行的在线系统。那么如何设计一个大规模的微服务系统呢?
图片来自 Pexels
这样的系统往往有以下的要求:
- 高可用。这类的系统往往需要保持一定的 SLA,7*24 时不间断运行不代表完全不挂,而是有一定的百分比的。
例如我们常说的可用性需达到 4 个 9(99.99%),全年停机总计不能超过 1 小时,约为 53 分钟,也即服务停用时间小于 53 分钟,就说明高可用设计合格。
- 用户分布在全国。大规模微服务系统所支撑的用户一般在全国各地,因而每个地区的人,都希望能够就近访问,所以一般不会一套系统服务全国,而是每个地区都要有相应的业务单元,使得用户可以就近访问。
- 并发量大,存在波峰波谷。微服务之所以规模比较大,其实是承载的压力比较大,而且需要根据请求的波峰波谷进行弹性伸缩。
- 有故障性能诊断和快速恢复的机制。大规模微服务场景下,运维人员很难进行命令式手动运维来控制应用的生命周期,应该采用声明式的运维方法。
另外一旦有了性能瓶颈或者故障点,应该有自动发现定位的机制,迅速找到瓶颈点和故障点,及时修复,才能保障 SLA。
战略设计
为了满足以上的要求,这个系统绝不是运维组努力一把,或者开发组努力一把,就能解决的,是一个端到端的,各个部门共同完成的一个目标,所以我们常称为战略设计。
研发
一个能支撑高并发,高可用的系统,一定是需要从研发环节就开始下功夫的。
首先,每一个微服务都有实现良好的无状态化处理,幂等服务接口设计
状态分为分发,处理,存储几个过程,如果对于一个用户的所有的信息都保存在一个进程中,则从分发阶段,就必须将这个用户分发到这个进程,否则无法对这个用户进行处理。
然而当一个进程压力很大的时候,根本无法扩容,新启动的进程根本无法处理那些保存在原来进程的用户的数据,不能分担压力。
所以要将整个架构分成两个部分,无状态部分和有状态部分,而业务逻辑的部分往往作为无状态的部分,而将状态保存在有状态的中间件中,如缓存,数据库,对象存储,大数据平台,消息队列等。
这样无状态的部分可以很容易的横向扩展,在用户分发的时候,可以很容易分发到新的进程进行处理,而状态保存到后端。
而后端的中间件是有状态的,这些中间件设计之初,就考虑了扩容的时候,状态的迁移,复制,同步等机制,不用业务层关心。
对于数据的存储,主要包含几类数据:
- 会话数据等,主要保存在内存中。对于保存在内存里的数据,例如 Session,可以放在外部统一的缓存中。
- 结构化数据,主要是业务逻辑相关。对于业务相关的数据,则应该保存在统一的数据库中。
- 文件图片数据,比较大,往往通过 CDN 下发。对于文件,照片之类的数据,应该存放在统一的对象存储里面。
- 非结构化数据,例如文本,评论等。对于非结构化数据,可以存在统一的搜索引擎里面,例如 ElasticSearch。
但是还有一个遗留的问题,就是已经分发,正在处理,但是尚未存储的数据,肯定会在内存中有一些,在进程重启的时候,数据还是会丢一些的,那这部分数据怎么办呢?
这部分就需要通过重试进行解决,当本次调用过程中失败之后,前序的进程会进行重试,例如 Dubbo 就有重试机制。
既然重试,就需要接口是幂等的,也即同一次交易,调用两次转账 1 元,不能最终转走 2 元。
接口分为查询,插入,更新,删除等操作:
- 对于查询接口来讲,本身就是幂等的,不用做特殊的判断。
- 对于插入接口来讲,如果每一个数据都有唯一的主键,也能保证插入的唯一性,一旦不唯一,则会报错。
- 对于更新操作来讲,则比较复杂,分两种情况。一种情况是同一个接口,前后调用多次的幂等性。另一种情况是同一个接口,并发环境下调用多次的正确性。
为了保持幂等性,往往要有一个幂等表,通过传入幂等参数匹配幂等表中 ID 的方式,保证每个操作只被执行一次,而且在实行最终一致性的时候,可以通过不断重试,保证最终接口调用的成功。
对于并发条件下,谁先调用,谁后调用,需要通过分布式锁如 Redis,ZooKeeper 等来实现同一个时刻只有一个请求被执行,如何保证多次执行结果仍然一致呢?则往往需要通过状态机,每个状态只流转一次。
还有就是乐观锁,也即分布式的 CAS 操作,将状态的判断、更新整合在一条语句中,可以保证状态流转的原子性。乐观锁并不保证更新一定成功,需要有对应的机制来应对更新失败。
其次,根据服务重要度实现熔断降级、限流保护策略
服务拆分多了,在应用层面就会遇到以下问题:
服务雪崩:即一个服务挂了,整个调用链路上的所有的服务都会受到影响。
大量请求堆积、故障恢复慢:即一个服务慢,卡住了,整个调用链路出现大量超时,要长时间等待慢的服务恢复到正常状态。
为了解决这些问题,我们在应用层面实施了以下方案:
通过熔断机制,当一个服务挂了,被影响的服务能够及时熔断,使用 Fallback 数据保证流程在非关键服务不可用的情况下,仍然可以进行。
通过线程池和消息队列机制实现异步化,允许服务快速失败,当一个服务因为过慢而阻塞,被影响服务可以在超时后快速失败,不会影响整个调用链路。
当发现整个系统的确负载过高的时候,可以选择降级某些功能或某些调用,保证最重要的交易流程的通过,以及最重要的资源全部用于保证最核心的流程。
还有一种手段就是限流,当既设置了熔断策略,又设置了降级策略,通过全链路的压力测试,应该能够知道整个系统的支撑能力。
因而就需要制定限流策略,保证系统在测试过的支撑能力范围内进行服务,超出支撑能力范围的,可拒绝服务。
当你下单的时候,系统弹出对话框说 “系统忙,请重试”,并不代表系统挂了,而是说明系统是正常工作的,只不过限流策略起到了作用。
其三,每个服务都要设计有效探活接口,以便健康检查感知到服务状态
当我们部署一个服务的时候,对于运维部门来讲,可以监控机器的状态或者容器的状态是否处于启动状态,也可以监控到进程是否启动,端口是否监听等。
但是对于已经启动的进程,是否能够正常服务,运维部门无法感知,需要开发每个服务的时候,设计一个有效探活接口,让运维的监控系统可以通过调用这个接口,来判断进程能够正常提供服务。
这个接口不要直接返回,而是应该在进程内部探查提供服务的线程是否出去正常状态,再返回相应的状态编码。
只有这样,开发出来的服务和运维才能合作起来,保持服务处于某个副本数,否则如果一部分服务虽然启动,但是处于假死状态,会使得其他正常服务,无法承受压力。
其四,通过制定良好的代码检查规范和静态扫描工具,最大化限制因为代码问题造成的系统不可用
要保持线上代码的高可用性,代码质量是关键,大部分线上问题,无论是性能问题,还是稳定性问题,都是代码造成的,而非基础设施造成的。
而且基础设施的可用率为 99.95%,但是服务层要求的可用率高于这个值,所以必须从业务层高可用来弥补。
除了下面的高可用架构部分,对于每一个服务来讲,制定良好的代码检查规范和静态扫描工具,通过大量的测试用例,最大化限制因为代码问题造成的系统不可用,是必须的,是高可用的基础。
高可用架构设计
在系统的每一个部分,都要避免单点。系统冗余往往分管控面和数据面,而且分多个层次,往往每一个层次都需要进行高可用的设计。
在机房层面,为了高可用应该部署在多个区域,或者多个云,每个区域分多个可用区进行部署。
对于云来讲,云的管控要多机房高可用部署,使得任何一个机房故障,都会使得管控依然可以使用。
这就需要管控的组件分布于至少两个机房,管控的数据库和消息队列跨机房进行数据同步。
对于云的数据面来讲,入口的网关要和机房网络配合做跨机房的高可用,使得入口公网 IP 和负载均衡器,在一个机房故障的情况下,可以切换至另一个机房。
在云之上要部署 Kubernetes 平台,管控层面 Kubernetes 要实现高可用部署,etcd 要跨机房高可用部署,Kubernetes 的管控组件也要跨机房部署。
当然还有一种情况是机房之间距离比较远,需要在每一个机房各部署一套 Kubernetes。
这种情况下,Kubernetes 的管控依然要实现高可用,只不过跨机房的高可用就需要应用层来实现了。
在应用层,微服务的治理平台,例如注册发现,ZooKeeper 或者 Euraka,APM,配置中心等都需要实现跨机房的高可用。另外就是服务要跨机房部署,实现城市级机房故障迁移能力。
运维
运维一个大规模微服务系统也有不一样的挑战。
首先,建议使用的是 Kubernetes 编排的声明式的运维方式,而非 Ansible 之类命令式的运维方式。
另外,对于系统的发布,要进行灰度、蓝绿发布,降低系统上线发布风险。要有这样的理念,任何一个新上线的系统,都是不可靠的。
所以可以通过流量分发的模式,逐渐切换到新的服务,从而保障系统的稳定。
其三,完善监控及应对机制,对系统各节点、应用、组件全面地监控,能够第一时间快速发现并解决问题。
监控绝非只有基础设施的 CPU,网络,磁盘的监控,应用的,业务的,调用链的监控都应该有。
而且对于紧急事件,应该有应急预案,应急预案是在高可用已经考虑过之后,仍然出现异常情况下,应该采取的预案,例如三个 etcd 全挂了的情况。
其四,持续关注线上系统网络使用、服务器性能、硬件存储、中间件、数据库灯指标,重点关注临界状态,也即当前还健康,但是马上可能出问题的状态。
例如网关 PPS 达到临界值,下一步就要开始丢包了,数据库快满了,消息出现大量堆积等等。
DBA
对于一个在线业务系统来讲,数据库是重中之重,很多的性能瓶颈定位到最后,都可能是数据库的问题。所以 DBA 团队要对数据库的使用,进行把关。
造成数据库性能问题,一方面是 SQL 语句的问题,一方面是容量的问题。
例如查询没有被索引覆盖,或者在区分度不大的字段上建立的索引,是否持锁时间过长,是否存在锁冲突等等,都会导致数据库慢的问题。
因而所有上线的 SQL 语句,都需要 DBA 提前审核,并且要对于数据库的性能做持续的监控,例如慢 SQL 语句等。
另外对于数据库中的数据量也要持续的监控,到一定的量就需要改分布式数据库 DDB,进行分库分表,到一定的阶段需要对分布式数据库进行扩容。
故障演练和性能压测
再好的规划也比不上演练,再好的性能评估也比不上在线的性能压测。
性能问题往往是通过线上性能压测发现的。线上压力测试需要有一个性能测试的平台,做多种形式的压力测试。
例如容量测试,通过梯度的加压,看到什么时候实在不行。摸高测试,测试在最大的限度之上还能承受多大的量,有一定的余量会保险一些,心里相对比较有底。
再就是稳定性测试,测试峰值的稳定性,看这个峰值能够撑一分钟,两分钟还是三十分钟。还有秒杀场景测试,限流降级演练测试等。
只有经过性能压测,才能发现线上系统的瓶颈点,通过不断的修复和扩容瓶颈点,最终才能知道服务之间应该以各种副本数的比例部署,才能承载期望的 QPS。
对于可能遇到的故障,可以进行故障演练,故意模拟一些故障,来看系统如何反应,是否会因为自修复,多副本,容错等机制,使得这些故障对于客户端来讲没有影响。
战术设计
下面,我们就从架构的每个层次,进行战术设计。我们先来看一下高可用部署架构选型以及他们的优劣:
高可用性要求和系统的负载度和成本是强相关的。越简单的架构,部署成本越低的架构,高可用性越小,例如上面的单体应用。
而微服务化,单元化,异地多活,必然导致架构复杂难以维护,机房成本比较高,所以要使用多少成本实现什么程度的高可用,是一个权衡。
高可用的实现需要多个层次一起考虑:
首先是应用层,可以通过异地多活单元保证城市级高可用,这样使得一个城市因为灾难宕机的时候,另外一个城市可以提供服务。
另外每个多活单元采用双机房保证机房级高可用,也即同城双机房,使得一个城市中一个机房宕机,另一个机房可以提供服务。
再者每个机房中采用多副本保证实例级高可用,使得一个副本宕机的时候,其他的副本可以提供服务。
其次是数据库层,在数据中心之间,通过主从复制或 MGR 实现数据异步复制,在每个集群单元中采用 DDB 分库分表,分库分表中的每个实例都是有数据库同步复制。
其三是缓存层,在数据中心之间,缓存采用多集群单元化复制,在每个集群单元中采用多副本主从复制。
其四微服务治理平台层,平台组件异地多活单元保证了城市级高可用,平台组件每个多活单元采用双机房保证机房级高可用,平台组件每个机房中采用多副本保证实例级高可用。
当有了以上高可用方案之后,则以下的故障等级以及影响时间如下表格:
接下来,我们每个层次详细论述。
应用层
下图以最复杂的场景,假设有三个城市,每个城市都有两个完全对等的数据中心。三个城市的数据中心也是完全对等的。
我们将整个业务数据按照某个维度分成 A,B,C 三部分。这样任何一部分全部宕机,其他部分照样可以提供服务。
对于有的业务,如果省级别的服务中断完全不能忍受,市级别的服务中断要求恢复时间相当短,而区县级别的服务中断恢复时间可以相对延长。
在这种场景下,可以根据地区来区分维度,使得一个区县和另外一个区县的数据属于不同的单元。
为了节约成本,模型可能会更加简化。中心节点和单元化节点不是对称的。中心节点可以实现同城双活,而异地单元化的部分只部署一个机房即可。这样是能满足大部分高可用性需求的。
这种架构要求实现中间件层和数据库层单元化,这个我们后面会仔细讲。
接入层
单元化要求 App 层或者在机房入口区域的接入层,实现中心单元和其他单元节点的流量分发。
对于初始请求没有任何路由标记的,可以随机分发给任何一个单元,也可以根据地区或者运营商在 GSLB 中分发给某个就近的单元。
应用层接收到请求以后,根据自己所在的单元生成路由信息,将路由信息返回给接入层或者 App。
接下来 App 或者接入层的请求,都会带着路由信息,选择相应的单元进行发送,从而实现了请求的处理集中在本单元。
中间件层
在中间件层,我们以 ZooKeeper 为例,分为以下两个场景:
场景一:ZooKeeper 单元化主从多活
在这种场景下,主机房和单元化机房距离相隔较近,时延很小,可以当做一个机房来对待。可以采用 ZooKeeper 高可用保障通过多 ZooKeeper 实例部署来达成。
如图所示,主机房 ZooKeeper 有 Leader 和 Follower,单元化机房的 ZooKeeper 仅为 Observer。
场景二:ZooKeeper 单元化多集群复制
两个机房相距较远,每个机房部署一套 ZooKeeper 集群,集群之间进行数据同步。
各机房应用连接机房内的 ZooKeeper 集群,注册的信息通过数据同步,能够被其他机房应用获取到。
单一机房 ZooKeeper 集群不可用,其余机房不受影响。当前不考虑做不同机房之间的集群切换。
数据库层
在数据库层,首先要解决的问题是,分布式数据库 DDB 集群多机房同步复制。
在单元内采用同城主从复制模式,跨单元采用 DTS/NDC 实现应用层数据双向同步能力。
对于数据的 ID 分配,应该采取全局唯一 ID 分配,有两种实现方式,如果主机房和单元化机房距离较近,可采用 ID 分配依然采用中心式, 所有机房的单元全部向同一中心服务申请 ID 的方式。
如果主机房和单元化机房相隔较远,可采用每个单元各自分配,通过特定规则保证每个机房得到的最终 ID 不冲突的方式。
缓存层
在缓存层,有两种方式:
方式一是集群热备,新增 Redis 集群作为热备份集群。
主集群与备份集群之间在服务端进行数据同步,通过 Redis Replication 协议进行同步处理。
离线监听主集群状态,探测到故障则进行主备之间切换,信息通过配置中心下达客户端,类哨兵方式进行监听探活。
在这种场景下,集群之间数据在服务端进行同步,正常情况下,集群之间数据会一致。但会存在一定的复制时延。
在故障切换时,可能存在极短时间内的数据丢失。如果将缓存仅仅当缓存使用,不要做内存数据库使用,则没有问题。
第二种方式,集群多活。新增集群作为多活集群,正常情况下客户端根据 Key 哈希策略选择分发到不同集群。
客户端通过 Proxy 连接集群中每一个节点,Proxy 的用处是区分客户端写入与集群复制写入。
集群之间在服务端进行数据双向复制,数据变更通过 Redis Replication 协议获取。
离线监听主集群状态,探测到故障则进行切换,信息通过配置中心下达客户端,类哨兵方式进行监听探活。
此方案应用于单纯的集群间高可用时,同一个 Key 在同一段时间内只会路由到同一个集群,数据一致性可以保证。
在故障切换情况下,可能存在极端时间内的数据丢失。
微服务治理平台
作为大规模微服务的微服务治理平台,一方面自己要实现单元化,另外一方面要实现流量在不同单元之间的染色与穿梭。
从 API 网关,NSF 服务治理和管理中心,APM 性能管理,GXTS 分布式事务管理,容器平台的管控都需要进行跨机房单元化部署。
当请求到达一个单元之后,API 网关上就带有此单元的路由信息,NSF 服务治理与管理平台在服务之间相互调用的时候,同样会插入此单元的路由信息。
当一个单元某实例全挂的时候,可以穿梭到另一个单元进行调用,并在下一跳调用回本单元,这种方式称为流量染色。