Golang进阶5- Go 架构实践-微服务(微服务可用性设计)

830

好书推荐

the-site-reliability-workbook 2

隔离

定义

隔离,本质上是对系统或资源进行分割,从而实现当系统发生故障时能限定传播范围和影响范围,即发生故障后只有出问题的服务不可用,保证其他服务仍然可用。

  • 部署的时候 一般都是N+2副本 最少2副本

CQRS模式

本质上就是读写的分离 《DDD 中的那些模式 — CQRS》 zhuanlan.zhihu.com/p/115685384

  • cacheline false sharing

服务隔离

动静分离

动静隔离:

  • 小到 CPU 的 cacheline false sharing
  • 数据库 mysql 表设计中避免 bufferpool 频繁过期,隔离动静表,
  • 大到架构设计中的图片、静态资源等缓存加速。

本质上都体现的一样的思路,即加速/缓存访问变换频次小的。比如 CDN 场景中,将静态资源和动态 API 分离,也是体现了隔离的思路:

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

读写分离

  • 建议创建表的时候都加上create_at update_at 读写分离:主从、Replicaset、CQRS。

archive: 稿件表,存储稿件的名称、作者、分类、tag、状态等信息,表示稿件的基本信息。 在一个投稿流程中,一旦稿件创建改动的频率比较低。 archive_stat: 稿件统计表,表示稿件的播放、点赞、收藏、投币数量,比较高频的更新。

随着稿件获取流量,稿件被用户所消费,各类计数信息更新比较频繁。 MySQL BufferPool 是用于缓存 DataPage 的,DataPage 可以理解为缓存了表的行,那么如果频繁更新 DataPage 不断会置换,会导致命中率下降的问题,所以我们在表设计中,仍然可以沿用类似的思路,其主表基本更新,在上游 Cache 未命中,透穿到 MySQL,仍然有 BufferPool 的缓存。

轻重隔离

核心隔离

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

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

快慢隔离

我们可以把服务的吞吐想象为一个池,当突然洪流进来时,池子需要一定时间才能排放完,这时候其他支流在池子里待的时间取决于前面的排放能力,耗时就会增高,对小请求产生影响。

  • 如果一个请求 吃掉了很大的资源 后面雨点类型的请求 就会打空
  • 请求都打到一个topic中 因为这样是顺序IO
    • 问题: 所有的吞吐都在一个topic中 如果下游HDFS抖动 那么就会反压 影响到上层 上游的网关 (中间件)---> 将request路由到不同的kafka集群

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

按照各种纬度隔离:sink、部门、业务、logid、重要性(S/A/B/C)。 业务日志也属于某个 logid,日志等级就可以作为隔离通道。

热点隔离

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

  • 被动预热(小表广播)
    • 比如:运营数据(推荐 广告), 表非常小 每个进程冗余一份(mapjoin)
    • 从 remotecache 提升为 localcache,app 定时更新,甚至可以让运营平台支持广播刷新 localcache。
    • atomic.Value 做store
  • 主动预热
    • 比如直播房间页高在线情况下bypass 监控主动防御。
    • kit统计TOP的数据 将数据sharding

物理隔离

线程隔离

主要通过线程池进行隔离,也是实现服务隔离的基础。把业务进行分类并交给不同的线程池进行处理,当某个线程池处理一种业务请求发生问题时,不会讲故障扩散和影响到其他线程池,保证服务可用。 对于 Go 来说,所有 IO 都是 Nonblocking,且托管给了 Runtime,只会阻塞Goroutine,不阻塞 M,我们只需要考虑 Goroutine 总量的控制,不需要线程模型语言的线程隔离。

php线程池

问题 - 线程耗尽 导致请求无法处理, 如果某个服务请求慢了 线程就会占用

java线程池

  • 如果某个服务请求卡了, 那就快速报错faild fast, 将这个对应的服务的线程占用全部干掉 不再接受请求
  • r4j

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

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

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

进程隔离

容器化(docker),容器编排引擎(k8s)。 我们15年在 KVM 上部署服务; 16年使用 Docker Swarm; 17年迁移到 Kubernetes,到年底在线应用就全托管了,之后很快在线应用弹性公有云上线; 20年离线 Yarn 和 在线 K8s 做了在离线混部(错峰使用),之后计划弹性公有云配合自建 IDC 做到离线的混合云架构。

集群隔离

回顾 gRPC,我们介绍过多集群方案,即逻辑上是一个应用,物理上部署多套应用,通过 cluster 区分。 多活建设完毕后,我们应用可以划分为: region.zone.cluster.appid

账号多活业务

隔离 - Case Stduy

  • 早期转码集群被超大视频攻击,导致转码大量延迟。
    • 有人搞事情将大视频无限上传 导致服务卡 后面的小姐姐跳舞视频无法上传
    • 解决方案: 隔离 大 中 小视频的处理 这时就卡大视频的业务 不影响其他的业务
  • 入口Nginx(SLB)故障,影响全机房流量入口故障。
    • 机房的入口 所有的流量经过这
    • 超时控制有问题 大量的链接堆积
    • 解决方案: 隔离
  • 缩略图服务,被大图实时缩略吃完所有 CPU,导致正常的小图缩略被丢弃,大量503。
    • GIF 图片一帧一帧组成的 ,如果GIF卡了 会影响GIP JPG饿处理
    • 解决方案: 隔离
  • 数据库实例 cgroup 未隔离,导致大 SQL 引起的集体故障。
    • 一台物理机部署多个MYSQl实例 如果一个MYSQl实例 存在慢SQL会导致整机卡
    • cgroup隔离MYSQl实例,互不干扰
  • INFO 日志量过大,导致异常 ERROR 日志采集延迟。
    • 不同的日志 进入不同的 topic通道

超时控制

定义

超时控制,本质上就是 组件能够快速失效(fail fast),因为不希望等到断开的实例直到超时(傻等 容易产生堆积 然后就炸了)。 没有什么比挂起的请求和无响应的界面更令人失望。这不仅浪费资源,而且还会让用户体验变得更差。我们的服务是互相调用的,所以在这些延迟叠加前,应该特别注意防止那些超时的操作。

  • 如果对面的机器挂了 内核不会给你返回FIN包,
    • TCP的链接 基于心跳
    • TCP keepalive()

为什么初出现网络超时?

  • 网路传递具有不确定性。
    • 段子:光纤 接触到静电 出现丢包
  • 客户端和服务端不一致的超时策略导致资源浪费。
  • “默认值”策略。
  • 高延迟服务导致 client 浪费资源等待,使用超时传递: 进程间传递 + 跨进程传递。
  • 超时控制是微服务可用性的第一道关,良好的超时策略,可以尽可能让服务不堆积请求,尽快清空高延迟的请求,释放 Goroutine。
    • 核心不要让client去傻等
    • 控制好全链路的超时 要做超时传递

实际业务开发中,我们依赖的微服务的超时策略并不清楚,或者随着业务迭代耗时超生了变化,意外的导致依赖者出现了超时。

怎么定超时时间?

  • 君子协议 :服务提供者定义好 latency SLO,更新到 gRPC Proto 定义中,服务后续迭代,都应保证 SLO。

SLI 指标 SLO 目标 SLA 达到的话怎么样,达不到怎么样

避免出现意外的默认超时策略,或者意外的配置超时策略。 kit 基础库兜底默认超时,比如 100ms,进行配置防御保护,避免出现类似 60s 之类的超大超时策略。 配置中心公共模版,对于未配置的服务使用公共配置。

package google.example.library.v1;

service LibraryService {
    // Lagency SLO: 95th in 100ms, 99th in 150ms.
    rpc CreateBook(CreateBookRequest) returns (Book); 
    rpc GetBook(GetBookRequest) returns Book);
    rpc ListBooks(ListBooksRequest) returns (ListBooksResponse);
}

超时传递

超时传递:当上游服务已经超时返回 504, 但下游服务仍然在执行,会导致浪费资源做无用功。超时传递指的是把当前服务的剩余 Quota 传递到下游服务中,继承超时策略,控制请求级别的全局超时控制。

  • 进程内超时控制 一个请求在每个阶段(网络请求)开始前,就要检查是否还有足够的剩余来处理请求,以及继承他的超时策略,使用 Go 标准库的 context.WithTimeout。
func (c *asiiConn) Get(ctx context.Context, key string) (result *Item, err error) {
	c.conn.SetWriteDeadline(shrinkDeadline(ctx, c.writeTimeout))
	if _, err = fmt.Fprintf(c.rw, "gets %s\r\n", key); err != nil {

case

gRPC天然支持超时传递和级联取消

  1. A gRPC 请求 B,1s超时。
  2. B 使用了300ms 处理请求,再转发请求 C。
  3. C 配置了600ms 超时,但是实际只用了500ms。
  4. 到其他的下游,发现余量不足,取消传递。 在需要强制执行时,下游的服务可以覆盖上游的超时传递和配额。 在 gRPC 框架中,会依赖 gRPC Metadata Exchange,基于 HTTP2 的 Headers 传递 grpc-timeout 字段,自动传递到下游,构建带 timeout 的 context。

双峰分布:

  • 95%的请求耗时在100ms内,5%的请求可能永远不会完成(长超时)。
  • 要做两极分化

对于监控不要只看mean,可以看看耗时分布统计,比如 95th,99th。 设置合理的超时,拒绝超长请求,或者当Server 不可用要主动失败。 超时决定着服务线程耗尽。

超时 - Case Stduy

  • SLB 入口 Nginx 没配置超时导致连锁故障。
    • 没有配置proxy_timeout
    • 只要1S没有响应 直接抛出504 服务依赖的 DB 连接池漏配超时,导致请求阻塞,最终服务集体 OOM。
    • 慢sql 会导致数据库一直连接 下游服务发版耗时增加,而上游服务配置超时过短,导致上游请求失败。
    • 做好君子协议

过载保护

限流

降级

什么是降级?

  • 通过降级回复来减少工作量,或者丢弃不重要的请求。
  • 需要了解哪些流量可以降级,并且有能力区分不同的请求。
  • 通常提供降低回复的质量来答复减少所需的计算量或者时间。

自动降级需要考虑几个点:

  • 确定具体采用哪个指标作为流量评估和优雅降级的决定性指标(如,CPU、延迟、队列长度、线程数量、错误等)
    • 错误: 如果client的APi报错了 那么就调用mock的代码
    • CPU: 例如搜索服务 负载高 压力大的时候 用户反馈大量报错,为了不让服务雪崩 load scheduling 最好做一个手动的开关
  • 当服务进入降级模式时,需要执行什么动作
    • 降级的时候 可以只返回cache的数据 不进行下游的调用 返回低质量的数据
  • 流量抛弃或者优雅降级应该在服务的哪一层实现?是否需要在整个服务的每一层都实现,还是可以选择某个高层面的关键节点来实现
    • 优雅降级建议在BFS层 APIgateway做
    • 不要在下游进行降级操作 成本太高
  • 优雅降级不应该被经常触发
    • 通常触发条件现实了容量规划的失误,或者是意外的负载。
  • 演练
    • 代码平时不会触发和使用,需要定期针对一小部分的流量进行演练,保证模式的正常。
    • 关键时候别掉链子
  • 应该足够简单
    • 动作不要太复杂 就是恢复低质量的请求

降级的本质

两种逻辑进行判断 getFailback()可能是进行sync.map的操作 读取本地的localcache

  • 降级本质为: 提供有损服务。

    • 核心是播放, 有损的界面好过整个界面不可用
    • 视频相关、标签、评论数、视频播放区 每个模块
  • UI 模块化,非核心模块降级。

  • BFF 层聚合 API,模块降级。

  • 页面上一次缓存副本

    • 显示最后一次成功的页面数据
  • 默认值、热门推荐等。

  • 流量拦截 + 定期数据缓存(过期副本策略) CAS

处理策略

页面降级、

  • 延迟服务
    • 反压 等整个链路处理完成
  • 写/读降级、
    • 写请求禁掉
  • 缓存降级
    • 上一次的Localcache
  • 抛异常
  • 返回约定协议
  • Mock 数据
  • Fallback 处理

降级 - Case Study

客户端解析协议失败,app 奔溃

  • 原因:降级的数据 key不一致 值类型不一致, 客户端部分协议不兼容,导致页面失败
  • 解决:做好约定 不要对下游做任何的假设 一定要做好异常处理

local cache 数据源缓存,发版失效 + 依赖接口故障,引起的白屏。

  • 解决: local cache没有数据的话 降级到remote cache

没有 playbook,导致的 MTTR 上升。

  • 解决: 故障手册 平时一定演练

重试

什么时候重试?

当请求返回错误(例: 配额不足、超时、内部错误等),对于 backend 部分节点过载的情况下,倾向于立刻重试,但是需要留意重试带来的流量放大: 限制重试次数和基于重试分布的策略(重试比率: 10%)。 随机化、指数型递增的重试周期: exponential ackoff + jitter。

client 测记录重试次数直方图,传递到 server,进行分布判定,交由 server 判定拒绝。 - 熔断 只应该在失败的这层进行重试,当重试仍然失败,全局约定错误码“过载,无须重试”,避免级联重试。 - 避免级联重试: 产生错误的一层进行重试 抛出一个约定好的错误码,其他层识别后统统放弃重试

重试 - Case Study

Nginx upstream retry 过大,导致服务雪崩

- 解决: 某个节点重新的比例超过一定比例 不进行请求 每次进行for循环 看哪个节点满足条件

业务不幂等,导致的重试,数据重复。

  • 全局唯一 ID: 根据业务生成一个全局唯一 ID,在调用接口时会传入该 ID,接口提供方会从相应的存储系统比如 redis 中去检索这个全局 ID 是否存在,如果存在则说明该操作已经执行过了,将拒绝本次服务请求;否则将相应该服务请求并将全局 ID 存入存储系统中,之后包含相同业务 ID 参数的请求将被拒绝。
  • 去重表: 这种方法适用于在业务中有唯一标识的插入场景。比如在支付场景中,一个订单只会支付一次,可以建立一张去重表,将订单 ID 作为唯一索引。把支付并且写入支付单据到去重表放入一个事务中了,这样当出现重复支付时,数据库就会抛出唯一约束异常,操作就会回滚。这样保证了订单只会被支付一次。
  • 多版本并发控制: 适合对更新请求作幂等性控制,比如要更新商品的名字,这是就可以在更新的接口中增加一个版本号来做幂等性控制。 多层级重试传递,放大流量引起雪崩。

负载均衡

我们发现在 backend 之间的 load 差异比较大: 每个请求的处理成本不同。 物理机环境的差异: 服务器很难强同质性。 存在共享资源争用(内存缓存、带宽、IO等)。 性能因素: FullGC。 JVM JIT。 参考JSQ(最闲轮训)负载均衡算法带来的问题,缺乏的是服务端全局视图,因此我们目标需要综合考虑:负载+可用性。

最佳实践

按照SOP标准去做事情 故障分两种 - 负责任(人为事故) 不按标准 规范流程做事 - 质量要靠管理手段 不仅仅是代码 - 无责任

问题

这样会产生更多的join不是吗 logid是什么维度的id 类似于Head-of-line blocking webp hdfs 也是跑在docker里吗 主动防御 logID怎么生成 Cgroup限制隔离mysql实例这个如何现实的?