文章目录
- 隔离的应用场景
- 隔离的措施
- 机房隔离
- 实例隔离
- 分组隔离
- 连接池隔离和线程池隔离
- 第三方依赖隔离
- 慢任务隔离
- 隔离的缺点
- 更多思考
- 超时控制
- 超时控制目标
- 超时控制形态
- 确定超时时间
- 1、根据用户体验来确定
- 2、根据响应时间来确定
- 3、压力测试
- 4、根据代码计算
- 超时中断业务
- 更多思考
前文参考:
- 微服务架构中的 熔断和降级
- 微服务架构中的 限流和压测
隔离的应用场景
在实际的应用中,“隔离”相比“限流”应用的场景要小一些,尤其是在中小型公司,很多时候是用不到“隔离”的。但是,如果要构建高可用和高性能的微服务架构,依然少不了“隔离”的使用。因为 在出现故障的时候,隔离可以把影响限制在一个可以忍受的范围内。
比如,某个系统中,可以为花了钱的VIP用户提供单独的服务集群,而免费的普通用户共享另外的服务集群。那么当普通用户的集群出了问题的时候,VIP 用户依然可以正常使用,这样就可以保证VIP用户体验不受损。特别是复杂的、核心的和规模庞大的服务,隔离机制就更加重要了。
隔离在实际工作中有很多种做法,从不同的角度可以进行不同类型的隔离。一般来说,使用隔离策略主要是为了达到如下的目的:
- 提升可用性,防止被别人影响,或防止影响别人。这部分也叫做故障隔离。
- 提升性能, 这是隔离和熔断、降级、限流不同的地方,一些隔离方案能够提高系统性能,而且有时候甚至能做到数量级提升。
- 提升安全性, 也就是为安全性比较高的系统提供单独的集群、使用更加严苛的权限控制、迎合当地的数据合规要求等。
一般的原则是 核心与核心隔离,核心与非核心隔离。
注意,这里有一个常见的误解,很多人认为核心服务可以放在一起,实际上并不是。
举例来说,如果核心服务都放在同一台机器上,那么这台机器一宕机,所有的核心服务就都宕机了。反过来说,如果核心服务部署在了不同的机器上,那么其中一台机器宕机了,也就只有这台机器上的服务崩了,而其他机器上的服务还是可以继续运行。
隔离的措施
隔离应该怎么样做才能做到 提升可用性、提升性能和提升安全性的目标呢?其实可以采取如下的措施:
机房隔离
机房隔离就是会把核心业务单独放进一个机房隔离,不会和其他不重要的服务混在一起。这个机房可能会有更加严格的变更流程、管理措施和权限控制,所以它的安全性会更高。
一些公司的金融支付业务,个人隐私类的往往会有独立的机房,或者至少在逻辑上它们会有完全不同的安全策略和保护措施。还有一些公司受制于当地的法律法规,例如数据必须留在本地。那么这些公司也只能说一个国家或一个地区一个机房。在这种形态下,其中一个机房崩溃了自然不会对另外一个机房有任何影响。
机房隔离 和 多活 看起来有点儿像,但是从概念上来说差异还是挺大的。这里的隔离指的是不同服务分散在不同的机房,而多活强调的是同一个服务在不同的城市、不同的机房里面有副本。
实例隔离
实例隔离是指某个服务独享某个实例的全部资源。比如说你在云厂商里面买了一个 4核8G 的机器,实例隔离就是指服务独享了这个实例,没有和其他组件共享。
但是这种隔离并没有考虑到这么一种情况,就是虽然你买了很多实例,但是这些实例在云厂商那里都是同一个物理机虚拟出来的。这种情况下,如果物理机有故障,那么这些虚拟机都会出问题。
有时候为了节省成本,一些不太重要的服务就可能会共享同一个实例,特别是测试环境,经常在一台机器上部署多个服务,如果一个服务消耗资源过多,比如说把 CPU 打满,所有人的测试服务就都跟着崩了。
分组隔离
分组隔离其实就是典型的微服务框架分组功能的应用,它通常是指一起部署的服务上有多个接口或者方法,那么就可以利用分组机制来达成隔离的效果。
- B 端一个组,C 端一个组。
- 普通用户一个组,VIP 用户一个组。
- 读接口一个组,写接口一个组。这种也叫做读写隔离。比如说在生产内容的业务里面,没有实行制作库和线上库分离的话,那么就可以简单地把读取内容划分成一个组,编辑内容划分成另外一个组。
- 快接口一个组,慢接口一个组。这个和前面的读写隔离可能会重叠,因为一般来说读接口会比较快。
比如,为了保障 C 端用户的服务体验,可以利用微服务框架的分组功能做一个简单的隔离:假设部署了8个实例,那么可以将其中3台实例分组为 B 端,于是商家过来的请求就只会落在这3台机器上;而 C 端用户的请求就可以落到8台中的任意一台。这么做的核心目的是限制住 B 端使用的资源,但是 C 端就没有做任何限制。
连接池隔离和线程池隔离
这两种都可以看作是池子隔离,只不过一个池子里面放的是连接,另一个池子里面放的是线程。而且连接池和线程池都不必局限在微服务领域,例如数据库连接池也是同样可以做隔离的。
这两种措施针对的是同一个进程内的不同服务,一般的做法都是给核心服务单独的连接池和线程池。这么做对于性能的改进也是很有帮助的,尤其是连接池隔离。
线程池隔离在 Java 里面的应用比较广泛。
第三方依赖隔离
第三方依赖隔离是指为核心服务或者热点专门提供数据库集群、消息队列集群等第三方依赖集群。比如:
- 给核心业务不同的 Redis 集群,只要核心业务的 Redis 没有崩溃,不重要的业务的 Redis 崩溃也不是那么难以接受。
- 对于不同类型的日志收集业务使用不同的kafka集群。
慢任务隔离
这个本质上也是 线程池隔离。比如下面的场景,我们会考虑开启一个线程池来处理。
- 异步任务,比如说收到请求之后直接返回一个已接收的响应,而后往线程池里面提交一个任务,异步处理这个请求。
- 定时任务,比如说每天计算一下热榜等。
这一类场景有一个潜在的隐患,就是 慢任务可能把所有的线程都占掉。举一个极端的例子,假如说线程池里最多有 100 个线程,而绝大多数任务在一秒内就可以执行完毕。然后某一时刻,来了 100 个至少需要一分钟的慢任务,这 100 个慢任务就会占据全部的线程,那么其他普通的任务全都得不到执行。要解决这种问题,就是要筛选出慢任务之后,将这些任务丢到一个单独的线程池里。
【问题分析】有时候定时任务总不能及时得到调度,后来加上监控之后,发现是因为存在少数执行很慢的任务,将线程池中的线程都占满了。后来引入了线程池隔离机制,核心就是让慢任务在一个专门的线程池里面执行。
【解决方案】准备两个线程池,一个线程池专门执行慢任务,另一个用来执行快任务。当任务开始执行的时候,先在快任务线程池里确定任务规模,识别出慢任务。比如说根据要处理的数据量的大小,分出慢任务。如果是快任务,就继续执行。否则,转交给慢任务线程池。
【关键点】这个方案的关键是如何识别慢任务。最简单的做法就是如果运行时间超过了一个阈值,那么就转交给慢任务线程池,这在识别循环处理数据里面比较好用,只需要在每次进入循环之前检测一下执行时长就可以了。而其他情况比较难,因为你没办法无侵入式地中断当前执行的代码,然后查看执行时长。也可以根据要处理的数据量来判断,比如先统计一下数据库有多少行是符合条件的,如果数据量很多,就转交给慢任务处理。
隔离的缺点
隔离操作本身是存在代价的。
-
一方面,隔离可能会带来资源浪费。例如为核心业务准备一个独立的 Redis 集群,它的效果确实很好,性能很好,可用性也很好,但是代价就是需要更多钱。
-
另一方面,隔离还容易引起资源不均衡的问题。比如说,在连接池隔离里面,可能两个连接池其中一个已经满负荷了,另外一个还是非常轻松。当然,公司有钱的话就另当别论。
更多思考
- 数据库方面:你们公司有几个物理上的数据库(包括主从集群),有没有业务是独享某一个物理数据库的。
- 你们公司有没有准备多个 Redis 实例或者多个集群。另外理论上来说开启了持久化功能或者被用作消息队列的 Redis 最好是一个独立的集群,防止影响正常将 Redis 用作缓存的业务。
- 其他类似的中间件,包括消息队列、Elasticsearch等,是否针对不同业务启用了不同的集群。
- 对核心业务、热点业务在资源配置上有没有什么特别之处。
- 在业务上,有没有针对高价值用户做什么资源倾斜。
- 在具体的系统上,有没有使用连接池隔离、线程池隔离等机制。
- 因为缺乏隔离机制引起的事故报告。
超时控制
超时控制是指在规定的时间内完成操作,如果不能完成,那么就返回一个超时响应。比如这个问题:调用某个接口时的超时时间应该设定为多久?以及你为什么认为这个超时设置是合理的?
关于超时控制,可以从下面几个维度去考虑:
- 超时控制的目标或者说好处。
- 超时控制的形态。
- 如何确定超时时间?
- 超时之后能不能中断业务?
- 谁来监听超时时间?
超时控制目标
超时控制有两个目标:一是 确保客户端能在预期的时间内拿到响应。这其实是用户体验的一个重要理念:“坏响应也比没响应好”。二是 及时释放资源。主要考虑线程和连接这两种资源。
- 【释放线程】:在超时的情况下,客户端收到了超时响应之后就可以继续往后执行,等执行完毕,这个线程就可以被用于执行别的业务。而如果没有超时控制,那么这个线程就会被一直占有。
- 【释放连接】:连接可以是 RPC 连接,也可以是数据库连接。如果没有拿到响应,客户端会一直占据这个连接。
及时释放资源是提高系统可用性的有效做法,现实中经常遇到的一类事故就是因为缺乏超时控制引起了连接泄露、线程泄露。
正常来说,对任何第三方的调用都应该设置超时时间。如果没有设置超时时间或者超时时间过长,都可能引起资源泄露。比如说某个同事的数据库查询超时时间设置得过长,在数据库性能出现抖动的时候,客户端的所有查询都被长时间阻塞,就有可能导致连接池中的连接耗尽。
超时控制形态
超时控制从形态上来看分成两种:
- 调用超时控制,比如说你在调用下游接口的时候,为这一次调用设置一个超时时间。
- 链路超时控制,是指整条调用链路被一个超时时间控制。比如说你的业务有一条链路是 A 调用 B,B 调用 C。如果链路超时时间是 1s,首先 A 调用 B 的超时时间是 1s,如果 B 收到请求的时候已经过去了 200ms,那么 B 调用 C 的超时时间就不能超过 800ms。
链路超时控制在微服务架构里面用得比较多,一般在核心服务或者非常看重响应时间的服务里面采用。
链路超时控制和普通超时控制最大的区别是链路超时控制会作用于整条链路上的任何一环。例如在 A 调用 B,B 调用 C 的链路中,如果 A 设置了超时时间 1s,那么 A 调用 B 不能超过 1s。然后当 B 收到请求之后,如果已经过去了 200ms,那么 B 调用 C 的超时时间就不能超过 800ms。因此链路超时的关键是 在链路中传递超时时间。
那么,怎么传递超时时间?
大部分情况下,链路超时时间在网络中传递是放在协议头的。如果是 RPC 协议,那么就放在 RPC 协议头,比如说 Dubbo 的头部;如果是 HTTP 那么就是放在 HTTP 头部。比较特殊的是 gRPC 这种基于 HTTP 的 RPC 协议,它是利用 HTTP 头部作为 RPC 的头部,所以也是放在 HTTP 头部的。
【问题】如果 A 调用 B,B 调用 C 的这条链路的超时时间设置为 1s,但是 B 这个服务的提供者就说自己是不可能在 1s 内返回响应的,那么该怎么办?
【回答】这个时候最好的做法是强制要求 B 优化它的性能。比如说产品经理明确说这条链路就是要在 1s 内返回,那么 B 就应该去优化性能,而不是在这里抱怨不可能在 1s 内返回。不过要是 A 本身超时时间可以妥协的话,那么 A 调大一点也可以。
确定超时时间
常见的4种确定超时时间的方式是 根据用户体验来确定、根据被调用接口的响应时间来确定、根据压测结果来确定、根据代码来确定。
超时时间要设置合理,过长可能会因为资源释放不及时而出事故,过短可能调用者会频繁超时,然后导致业务几乎没有办法执行。
1、根据用户体验来确定
比如说产品经理认为这个用户最多只能在这里等待 300ms,那么你的超时时间就最多设置为 300ms。但如果仅仅依靠用户体验来决定超时时间也是不现实的,比如说当你去问产品经理某个接口对性能要求的时候,他让你看着办。那么这个时候你就要选择下一种策略了。
2、根据响应时间来确定
在实践中,大多数时候都是根据被调用接口的响应时间来确定超时时间。一般情况下,你可以选择使用 99 线 或者 999 线 来作为超时时间。
所谓的 99 线是指 99% 的请求的响应时间都在这个值以内。比如说 99 线为 1s,那么意味着 99% 的请求响应时间都在 1s 以内。999 线也是类似的含义。
但是使用这种方式要求这个接口已经接入了类似 Prometheus 之类的可观测性工具,能够算出 99 线或者 999 线。如果一个接口是新接口,你要调用它,而这时候根本没有 99 线或者 999 线的数据。那么你可以考虑使用压力测试。
3、压力测试
简单来说,你可以通过压力测试来找到被调用接口的 99 线和 999 线,而且压力测试应该尽可能在和线上一样的环境下进行。但是很多公司其实并没有压测环境,也不可能让你停下新功能开发去做压力测试。那么就无法采用压力测试来采集到响应时间数据。
所以你就只剩下最后一个手段,根据代码来计算。
4、根据代码计算
假如说你现在有一个接口,里面有三次数据库操作,还有一次访问 Redis 的操作和一次发送消息的操作,那么你接口的响应时间就应该这样计算:
接 口 的 响 应 时 间 = 数 据 库 响 应 时 间 × 3 + R e d i s 响 应 时 间 + 发 送 消 息 的 响 应 时 间 接口的响应时间=数据库响应时间 × 3 + Redis 响应时间 + 发送消息的响应时间 接口的响应时间=数据库响应时间×3+Redis响应时间+发送消息的响应时间
如果你觉得不保险,那么可以在计算出来的结果上再加一点作为余量。比如说你通过分析代码认为响应时间应该在 200ms,那么你完全可以加上 100ms 作为余量。你可以告诉这个接口的调用者,将超时时间设置为 300ms。
超时中断业务
所谓的中断业务是指,当调用一个服务超时之后,这个服务还会继续执行吗? 答案是基本上会继续执行,除非服务端自己主动检测一下本次收到的请求是否已经超时了。
举例来说,如果你的业务逻辑有 A、B、C 三个步骤。假如说你执行到 B 的时候超时了,如果你的代码里面没有检测到,那么还是会继续执行 C。但是如果你 主动 检测了超时,那么你就可以在 B 执行之后就返回。
但是正常在实践中,我们是不会写这种手动检测的繁琐代码的。所以经常出现一个问题,就是客户端虽然超时了,但是实际上服务端已经执行成功了。比如 用户第一次提交注册的时候拿到了超时响应,返回“注册失败”,但是实际上他注册成功了,数据库写入了注册信息。当他第二次重试注册的时候,服务端会返回“此用户已存在!”。
更多思考
- 你所在公司的核心业务,尤其是App首页之类的,公司层面上的性能要求是什么?也就是说响应时间必须控制在多少以内,然后进一步了解有没有采用链路超时控制。
- 你自己维护的服务调用下游的时候有没有设置超时时间,超时时间都是多长?
- 数据库查询有没有设置超时时间?
- 跟任何第三方中间件打交道的代码有没有设置超时时间?例如查询 Redis,发送消息到 Kafka、调用三方接口 等。