微服务可用性设计之隔离

隔离

隔离,本质上是对系统或资源进行分割,从而实现当系统发生故障时,能限定传播范围和影响范围。(也就是当一个资源不可用,则将这个无法使用的资源隔离出系统之外)

即发生故障后只有出问题的服务不可用,保证其他服务仍然可用。

服务隔离的方式

  • 动静分离:通过 CDN 进行静态资源加速
  • 读写分离:CQRS,一个进程负责写入,另外一个进程负责写入,时间差几秒钟可以接受的情况。

轻重隔离

  • 核心:
  • 快慢:
  • 热点:

物理隔离

  • 线程:
  • 进程:
  • 集群:
  • 机房:

服务隔离

动静隔离

小到 CPUcacheline false sharing、数据库 mysql 表设计中避免 bufferpool 频繁过期,隔离动静表(缓存小表,增加 buffer pool 命中率。),大到架构设计中的图片、静态资源等缓存加速。本质上都是体现的一样的思路,即加速、缓存访问变换频次小的。

“缓存行伪共享”(cacheline false sharing)是指在多线程编程中,当多个线程同时访问位于同一缓存行(cacheline)中的不同变量时,由于缓存一致性协议的机制,可能导致额外的性能开销。尽管这些变量彼此之间没有直接的依赖关系,但由于它们位于同一缓存行中,当一个线程修改其中一个变量时,会导致整个缓存行无效,从而使其他线程需要重新从主内存中读取自己所需的变量。

这种现象会降低程序的性能,因为频繁的缓存行无效和内存访问会导致额外的延迟。为了减少缓存行伪共享的影响,可以采取措施,如通过调整变量的布局,使得不同的变量位于不同的缓存行,或者使用填充字节(padding)来确保变量之间有足够的间隔,避免它们位于同一缓存行中。这样可以减少缓存行无效的次数,提高程序的性能。

image-20231019174246865

比如 CND 场景中,将静态资源和动态资源 API 分离,也是体现了隔离的思路:

  • 降低应用服务器负载,静态文件访问负载全部通过 CDN
  • 对象存储存储费用最低
  • 海量存储空间,无需考虑存储架构升级
  • 静态 CDN 带宽加速,延迟低

又例如稿件表:

image-20231019174926957

  • archive:稿件表,存储稿件的名称、作者、分类、tag、状态等信息,表示稿件的基本信息。

    在一个投稿流程中,一旦稿件创建改动的频率比较低

  • archive_stat:稿件统计表,表示稿件的播放、点赞、收藏、投币数量,比较高频的更新。

    随着稿件获取流量,稿件被用户所消费,各类计数信息更新比较频繁

MySQL BufferPool 是用于缓存 DataPage 的,DataPage 可以理解为缓存了表的行,那么如果频繁更新 DataPage 可以理解为缓存了表的行,那么如果频繁更新, DataPage 不断会置换,会导致命中率下降的问题。所以在表设计中,仍然可以沿用类似的思路,其主表基本更新,在上游 Cache 未命中,透穿到 MySQL,仍然有 BufferPool的缓存。

读写分离

  • 主从:对一致性要求不高的,从库通过 binlog 实现更新,读取请求
  • Replicaset:微服务多副本机制,增加读的性能,增加冗余
  • CQRS:读写在进程上分离,针对读写是两种角色的操作,而进行的分发。例如草稿库的设计,草稿库同步到先上库的时候,同时刷新缓存,保证缓存一直是最新的,而且不必设置过期时间,或者不同的库、相同库不同的表,保证读和写是不同的对象。

轻重隔离

核心是根据资源的重要性,将重要资源和不重要资源隔离开来,同时重要资源之间也相互隔离,以保证一些资源崩溃的时候,另外一些资源不会受到影响。资源一般就是服务和数据。而判断是否需要隔离的标准则很多,可以是业务的业务价值,服务 QPS,数据的价值,数据被访问 QPS 等,由此引申出来了核心隔离、快慢隔离、热点隔离。

核心隔离

业务按照 Level 进行资源池划分(L0/L1/L2)

image-20231019175646633

  • 核心/非核心的故障域的差异隔离(机器资源、依赖资源)
  • 多集群,通过冗余资源来提升吞吐和容灾能力

快慢隔离

如果将服务的吞吐想象为一个池,当突然洪流进来时(大量的请求,或者小请求但是请求吃很多资源),池子需要一定时间才能排放完(处理某一个请求,导致其他请求无法处理),这时候其他支流在池子里待的时间取决于前面的排放能力,耗时就会增高,对小请求产生影响。

image-20231019180436302

日志传输体系的架构设计中,整个流都会投放到一个 kafka topic 中(早期设计目的:更好的顺序 IO ),流内会区分不同的 logidlogid 会有不同的 sink 端,他们之前会出现差速,比如 HDFS 抖动吞吐下降, ES 正常水位,全局数据就会整体反压。

  • 按照各种维度隔离:sink、部门、业务、logid、重要性(S/A/B/C)

业务日志也属于某个 logid,日志等级就可以作为隔离通道。 (变成 logid 之后隔离性更好)

热点隔离

热点即经常访问的数据。

很多时候我们希望统计某个热点数据中访问频次最高的 Top K 数据,并对其访问进行缓存。比如:

  • 小表广播(被动预热):从 remotecache 提升为 localcacheapp 定时更新,甚至可以让运营平台支持广播刷新 localcache。使用 atomic.Value

    image-20231020100002346

  • 主动预热:比如直播房间页高在线情况下 bypass 旁路监控主动防御。

    image-20231020100026520

物理隔离

比较多的是指进程隔离、线程隔离和集群隔离。

线程隔离在 Java 这种语言比较常见,一般是线程池隔离。

go 的框架在这方面使用得比较少,主要是因为 goroutine 非常轻量。在一些特别复杂的框架里面也会有应用 goroutine 池,但是总体来说不是主流,复杂性太多而收益太小。

目前来说,大规模采用 docker 之类的容器技术应该算是最为典型的。

线程隔离

主要通过线程池进行隔离,也是实现服务隔离的基础。把业务进行分类并交给不同的线程池进行处理,当某个线程池处理一种业务请求发生问题时,不会将故障扩散和影响到其他线程池,保证服务可用。

例如 Tomcat 的线程池,需要同时处理用户服务、推荐服务、积分服务。

image-20231020100533281

当推荐服务无可用线程导致调用失败,有效的线程隔离可以不影响用户服务和积分服务。

image-20231020101208490

image-20231020101213671

对于 Go 来说,所有 IO 都是 Nonblocking,且托管给了 Runtime,只会阻塞 Goroutine,不阻塞 M,所以只需要考虑 Goroutine 总量的控制,不需要线程模型语言的线程隔离。

Java 中,除了线程池隔离,也有基于信号量的做法。

image-20231020101447494

当信号量达到 maxConcurrentRequests 后,再请求会触发 fallback

image-20231020101605562

而线程池则是当线程池达到 maxSize 后,再请求会触发 fallback 接口进行熔断。

进程隔离

容器化(docker),容器编排引擎(k8s)。比如早期在 KVM 上部署服务,到 Docker Swarm;再到 Kubernetes,去全部托管在线应用;迁移到弹性公有云;离线Yarn 和在线 k8s 做离线混部(错峰使用),之后计划弹性公有云配合自建 IDC 做到离线的混合云架构。

集群隔离

例如 gRPC,可以使用多集群方案,在 metadata 中通过租户实现隔离,以及多集群方案,逻辑上虽然是一个应用,但是物理上部署多套应用,通过 cluster 区分。

账号多活

多活减少完毕后,应用可以划分为:region.zone.cluster.appid

Case Stduy

  • 早期转码集群被超大视频攻击,导致转码大量延迟;

    • 进行集群隔离,大视频和小视频隔离
    • 进行用户标签隔离,将用户的垃圾视频隔离
  • 入口 Nginx(SLB)故障,影响全机房流量入口故障

    • 将异常流量切换到其他的 SLB 上,避免影响核心交换,进而影响全局 SLB
  • 缩略图服务,被大图实时缩略吃完所有 CPU,导致正常的小图缩略被丢弃,大量 503

    • 与转码一样,可以通过大图片和小图片隔离
  • 数据库实例 cgroup 未隔离,导致大 SQL 引起的集群故障

    通过 cgroup 限制 CPU 和内存

  • INFO 日志量过大,导致异常 ERROR 日志采集延迟

推荐阅读

阿里微服务之殇及分布式链路追踪技术原理