[TOC]
系统设计:关于高可用系统的一些技术方案
可靠的系统是业务稳定、快速发展的基石。那么,如何做到系统高可靠、高可用呢?下面从技术方面介绍几种提高系统可靠性、可用性的方法。
扩展
扩展是最常见的提升系统可靠性的方法,系统的扩展可以避免单点故障,即一个节点出现了问题造成整个系统无法正常工作。换一个角度讲,一个容易扩展的系统,能够通过扩展来成倍的提升系统能力,轻松应对系统访问量的提升。
一般地,扩展可以分为垂直扩展和水平扩展:
- 垂直扩展:是在同一逻辑单元里添加资源从而满足系统处理能力上升的需求。比如,当机器内存不够时,我们可以帮机器增加内存,或者数据存不下时,我们为机器挂载新的磁盘。
- 垂直扩展能够提升系统处理能力,但不能解决单点故障问题。
- 优点:扩展简单。
- 缺点:扩展能力有限。
- 水平扩展:通过增加一个或多个逻辑单元,并使得它们像整体一样的工作。
- 水平扩展,通过冗余部署解决了单点故障,同时又提升了系统处理能力。
- 优点:扩展能力强。
- 缺点:增加系统复杂度,维护成本高,系统需要是无状态的、可分布式的。
可扩展性系数 scalability factor 通常用来衡量一个系统的扩展能力,当增加 1 单元的资源时,系统处理能力只增加了 0.95 单元,那么可扩展性系数就是 95%。当系统在持续的扩展中,可扩展系数始终保持不变,我们就称这种扩展是线性可扩展。
在实际应用中,水平扩展最常见:
- 通常我们在部署应用服务器的时候,都会部署多台,然后使用 nginx 来做负载均衡,nginx 使用心跳机制来检测服务器的正常与否,无响应的服务就从集群中剔除。这样的集群中每台服务器的角色是相同的,同时提供一样的服务。
- 在数据库的部署中,为了防止单点故障,一般会使用一主多从,通常写操作只发生在主库。不同数据库之间角色不同。当主机宕机时,一台从库可以自动切换为主机提供服务。
隔离
隔离,是对什么进行隔离呢?是对系统、业务所占有的资源进行隔离,限制某个业务对资源的占用数量,避免一个业务占用整个系统资源,对其他业务造成影响。
隔离级别按粒度从小到大,可以分为线程池隔离、进程隔离、模块隔离、应用隔离、机房隔离。在数据库的使用中,还经常用到读写分离。
- 线程池隔离:不同的业务使用不同的线程池,避免低优先级的任务阻塞高优先级的任务。或者高优先级的任务过多,导致低优先级任务永远不会执行。
- 进程隔离:Linux 中有用于进程资源隔离的 Linux CGroup,通过物理限制的方式为进程间资源控制提供了简单的实现方式,为 Linux Container 技术、虚拟化技术的发展奠定了技术基础。在工作中的实际应用,可以看看这篇文章:日志压缩资源消耗优化: Linux CGroup 的使用。
- 模块隔离、应用隔离:很多线上故障的发生源于代码修改后,测试不到位导致。按照代码或业务的易变程度来划分模块或应用,把变化较少的划分到一个模块或应用中,变化较多的划分到另一个模块或应用中。减少代码修改影响的范围,也就减少了测试的工作量,减少了故障出现的概率。
- 机房隔离:主要是为了避免单个机房网络问题或断电吧。
- 读写分离:一方面,将对实时性要求不高的读操作,放到 DB 从库上执行,有利于减轻 DB 主库的压力。另一方面,将一些耗时离线业务 sql 放到 DB 从库上执行,能够减少慢 sql 对 DB 主库的影响,保证线上业务的稳定可靠。
解耦
在软件工程中,对象之间的耦合度就是对象之间的依赖性。对象之间的耦合越高,维护成本越高,因此对象的设计应使模块之间的耦合度尽量小。在软件架构设计中,模块之间的解耦或者说松耦合有两种,假设有两个模块A、B,A依赖B:
- 第一种是,模块A和模块B只通过接口交互,只要接口设计不变,那么模块B内部细节的变化不影响模块A对模块B服务能力的消费。
- 面向接口设计下真正实现了将接口契约的定义和接口的实现彻底分离,实现变化不影响到接口契约,自然不影响到基于接口的交互。
- 模块A和B之间的松耦合,主要通过合理的模块划分、接口设计来完成。如果出现循环依赖,可以将模块A、B共同依赖的部分移除到另一个模块C中,将A、B之间的相互依赖,转换为A、B同时对C的依赖。
- 第二种是,将同步调用转换成异步消息交互。
- 比如在买机票系统中,机票支付完成后需要通知出票系统出票、代金券系统发券。如果使用同步调用,那么出票系统、代金券系统宕机是会影响到机票支付系统,如果另一个系统比如专车系统也想要在机票支付完成后向用户推荐专车服务,那么同步调用模式下机票支付系统就需要为此而改动,容易影响核心支付业务的可靠性。
- 如果我们将同步调用替换成异步消息,机票支付系统发送机票支付成功的消息到消息中间件,出票系统、代金券系统从消息中间件订阅消息。这样一来,出票系统、代金券系统的宕机也就不会对机票支付系统造成任何影响了。专车系统想要知道机票支付完成这一事件,也只需要从消息中间件订阅消息即可,机票支付系统完全不需要做任何改动。
- 异步消息解耦,适合那些信息流单向流动(类似发布-订阅这样的),实时性要求不高的系统。常见的开源消息队列框架有:Kafka、RabbitMQ、RocketMQ。
限流
为什么要做限流呢?举一个生活中的例子,大家早上上班都要挤地铁吧,地铁站在早高峰的时候经常要限制客流,为什么呢?有人会觉得这是人为添堵。真是这样吗?如果不执行客流控制,大家想想会是什么场景呢?站台到处都挤满了乘客,就算你使出洪荒之力也不一定能顺利上车,且非常容易引发肢体碰撞,造成冲突。有了客流控制之后,地铁站才能变得秩序井然,大家才能安全上地铁。
一个系统的处理能力是有上限的,当服务请求量超过处理能力,通常会引起排队,造成响应时间迅速提升。如果对服务占用的资源量没有约束,还可能因为系统资源占用过多而宕机。因此,为了保证系统在遭遇突发流量时,能够正常运行,需要为你的服务加上限流。
常见的限流算法有:漏桶、令牌桶、滑动窗口计数。
分类
按照计数范围,可以分为:单机限流、全局限流。单机限流,一般是为了应对突发流量,而全局限流,通常是为了给有限资源进行流量配额。
按照计数周期,可以分为:QPS、并发(连接数)。
按照阈值设定方式的不同,可以分为:固定阈值、动态阈值。
漏桶算法
下面这张图,是漏桶的示意图。漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大时,会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。漏桶算法(Leaky Bucket)是网络世界中流量整形(Traffic Shaping)或速率限制(Rate Limiting)时经常使用的一种算法,它的主要目的是控制数据注入到网络的速率,平滑网络上的突发流量。
漏桶算法可以使用 Redis 队列来实现,生产者发送消息前先检查队列长度是否超过阈值,超过阈值则丢弃消息,否则发送消息到 Redis 队列中;消费者以固定速率从 Redis 队列中取消息。Redis 队列在这里起到了一个缓冲池的作用,起到削峰填谷、流量整形的作用。
令牌桶算法
对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。桶里能够存放令牌的最高数量,就是允许的突发传输量。
Guava 中的限流工具 RateLimiter,其原理就是令牌桶算法。
滑动窗口计数法
计数法是限流算法里最容易理解的一种,该方法统计最近一段时间的请求量,如果超过一定的阈值,就开始限流。在 TCP 网络协议中,也用到了滑动窗口来限制数据传输速率。
滑动窗口计数有两个关键的因素:窗口时长、滚动时间间隔。滚动时间间隔一般等于上图中的一个桶 bucket,窗口时长除以滚动时间间隔,就是一个窗口所包含的 bucket 数目。
滑动窗口计数算法的实现,可以查看这篇文章:降级熔断框架 Hystrix 源码解析:滑动窗口统计。
动态限流
一般情况下的限流,都需要我们手动设定限流阈值,不仅繁琐,而且容易因系统的发布升级而过时。为此,我们考虑根据系统负载来动态决定是否限流,动态计算限流阈值。可以参考的系统负载参数有:Load、CPU、接口响应时间等。
降级
业务降级,是指牺牲非核心的业务功能,保证核心功能的稳定运行。简单来说,要实现优雅的业务降级,需要将功能实现拆分到相对独立的不同代码单元,分优先级进行隔离。在后台通过开关控制,降级部分非主流程的业务功能,减轻系统依赖和性能损耗,从而提升集群的整体吞吐率。
降级的重点是:业务之间有优先级之分。降级的典型应用是:电商活动期间关闭非核心服务,保证核心买买买业务的正常运行。
业务降级通常需要通过开关工作,开关一般做成配置放在专门的配置系统,配置的修改最好能够实时生效,毕竟要是还得修改代码发布那就太 low 了。开源的配置系统有阿里的diamond、携程的Apollo、百度的disconf。
降级往往需要兜底方案的配合,比如系统不可用的时候,对用户进行提示,安抚用户。提示虽然不起眼,但是能够有效的提升用户体验。
熔断
谈到熔断,不得不提经典的电力系统中的保险丝,当负载过大,或者电路发生故障时,电流会不断升高,为防止升高的电流有可能损坏电路中的某些重要器件或贵重器件,烧毁电路甚至造成火灾。保险丝会在电流异常升高到一定的高度和热度的时候,自身熔断切断电流,从而起到保护电路安全运行的作用。
同样,在分布式系统中,如果调用的远程服务或者资源由于某种原因无法使用时,没有这种过载保护,就会导致请求阻塞在服务器上等待从而耗尽服务器资源。很多时候刚开始可能只是系统出现了局部的、小规模的故障,然而由于种种原因,故障影响的范围越来越大,最终导致了全局性的后果。而这种过载保护就是大家俗称的熔断器(Circuit Breaker)。
下面这张图,就是熔断器的基本原理,包含三个状态:
- 服务正常运行时的 Closed 状态,当服务调用失败量或失败率达到阈值时,熔断器进入 Open 状态
- 在 Open 状态,服务调用不会真正去请求外部资源,会快速失败。
- 当进入 Open 状态一段时间后,进入 Half-Open状态,需要去尝试调用几次服务,检查故障的服务是否恢复。如果成功则熔断器关闭,如果失败,则再次进入 Open 状态。
目前比较流行的降级熔断框架,是由 Netflix 开源的 Hystrix 框架。
发布相关
模块级自动化测试
众所周知,一个项目上线前需要经历严格的测试过程,但是随着业务不断迭代、系统日益复杂,研发工程师、产品经理、测试工程师等都在测试过程中投入了大量精力,而一个个线上故障却表明测试效果并不是那么完美。究其原因,目前的测试工作主要存在两方面问题:
- 测试范围难以界定:随着业务逻辑的不断迭代、系统的不断拆分与细化,精确评估项目改动的影响范围变得越来越困难,从而很难梳理出覆盖全面的测试点。
- case验证成本过高:验证一个case需要构造测试场景,包括数据的准备和运行环境的准备,当case量较大或者存在一些涉及多个系统模块且触发条件复杂的case时,这一过程也将花费大量的时间。
解决上述问题可以使用模块级自动化测试。具体方案是:针对某一模块,收集模块线上的输入、输出、运行时环境等信息,在离线测试环境通过数据mock模块线上场景,回放收集的线上输入,相同的输入比较测试场景与线上收集的输出作为测试结果。
模块级自动化测试通过简化复杂系统中的不变因素(mock),将系统的测试边界收拢到改动模块,将复杂系统的整体测试转化为改动模块的单元测试。主要适用于系统业务回归,对系统内部重构场景尤其适用。
具体如何收集线上数据呢?有两种方法:
- AOP:面向切面编程,动态地织入代码,对原有代码的侵入性较小。
- 埋点:很多公司都开发了一下基础组件,可以在这些基础组件中嵌入数据收集的代码。
更多细节,可以查看下面参考文献中的文章:Qunar 自动化测试框架 ARES。
灰度发布 & 回滚
单点和发布是系统高可用最大的敌人。一般在线上出现故障后,第一个要考虑的就是刚刚有没有代码发布、配置发布,如果有的话就先回滚。线上故障最重要的是快速恢复,如果等你细细看代码找到问题,没准儿半天就过去了。
为了减少发布引起问题的严重程度,通常会使用灰度发布策略。灰度发布是速度与安全性作为妥协。他是发布众多保险的最后一道,而不是唯一的一道。在这篇文章来自 Google 的高可用架构理念与实践里提到:
做灰度发布,如果是匀速的,说明没有理解灰度发布的意义。一般来说阶段选择上从 1% -> 10% -> 100% 的指数型增长。这个阶段,是根据具体业务不同按维度去细分的。
这里面的重点在于 1% 并不全是随机选择的,而是根据业务特点、数据特点选择的一批有极强的代表性的实例,去做灰度发布的小白鼠。甚至于每次发布的 第一阶段用户(我们叫 Canary/金丝雀),根据每次发布的特点不同,是人为挑选的。
发布之前必须制定详细的回滚步骤,回滚是解决发布引起的故障的最快的方法。
其他
- 设置超时:请求对外接口的时候,需要设置合理的超时时间,避免外部接口挂掉时,阻塞整个系统。
- 失败重试:失败重试能够提高成功率,但是也会造成响应时间变慢,服务提供方压力倍增。具体要不要重试要根据具体情况决定:对响应时间有要求吗?接口失败率如何?重试会不会造成雪崩?
总结
技术 | 解决什么问题 |
---|---|
扩展 | 通过冗余部署,避免单点故障 |
隔离 | 1. 避免业务之间的相互影响 2. 机房隔离避免单点故障 |
解耦 | 减少依赖,减少相互间的影响 |
限流 | 遇到突发流量时,保证系统稳定 |
降级 | 牺牲非核心业务,保证核心业务的高可用 |
熔断 | 减少不稳定的外部依赖对核心服务的影响 |
自动化测试 | 通过完善的测试,减少发布引起的故障 |
灰度发布 | 灰度发布是速度与安全性作为妥协,能够有效减少发布故障 |
在这篇文章中,我们探讨了一些提供系统可靠性的技术方案。关于高可用的更多问题可以看看这篇文章 陈皓:关于高可用的系统,这篇文章的核心在于提出:
5个9的SLA在一年内只能是5分钟的不可用时间,5分钟啊,如果按一年只出1次故障,你也得在五分钟内恢复故障,让我们想想,这意味着什么?
如果你没有一套科学的牛逼的软件工程的管理,没有牛逼先进的自动化的运维工具,没有技术能力很牛逼的工程师团队,怎么可能出现高可用的系统啊。
是的,要干出高可用的系统,这TMD就是一套严谨科学的工程管理。